[
  {
    "path": "CONTRIBUTING.md",
    "content": "# How to Contribute\n\nWe'd love to accept your patches and contributions to this project. There are\njust a few small guidelines you need to follow.\n\n## Contributor License Agreement\n\nContributions to this project must be accompanied by a Contributor License\nAgreement. You (or your employer) retain the copyright to your contribution;\nthis simply gives us permission to use and redistribute your contributions as\npart of the project. Head over to <https://cla.developers.google.com/> to see\nyour current agreements on file or to sign a new one.\n\nYou generally only need to submit a CLA once, so if you've already submitted one\n(even if it was for a different project), you probably don't need to do it\nagain.\n\n## Code reviews\n\nAll submissions, including submissions by project members, require review. We\nuse GitHub pull requests for this purpose. Consult\n[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more\ninformation on using pull requests.\n\n## Community Guidelines\n\nThis project follows [Google's Open Source Community\nGuidelines](https://opensource.google.com/conduct/)."
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "# Gemini API Demos\n\nHello! 👋 This is a repository of examples built with Google's Gemini API, which lets you build multimodal AI applications with text, images, and more.\n\n## What You'll Find:\n\nExamples: These demos show the latest Gemini models (Flash 1.5, Pro, and others) in action. Dive into projects that demonstrate:\n\n- Image and Video understanding: Analyze content, classify objects, and even generate timestamped summaries.\n- Multimodal interaction: Combine text and image inputs to create engaging user experiences.\n- Technical Inspiration: Get hands-on with code examples that show you how to use the Gemini API effectively. Learn best practices for prompt engineering, caching and embedding, and integrating Gemini into your own applications.\n\n## Getting Started:\n\n1. Obtain an API Key: To use the Gemini API, you'll need an API key. You can get one [here](https://ai.google.dev/gemini-api/docs/api-key) or from [AI Studio](https://aistudio.google.com/app/apikey)\n2. Explore the Docs: The official documentation is your comprehensive guide to the Gemini API: https://ai.google.dev/gemini-api/docs/\n3. Dive into the Demos: Choose a demo that sparks your interest and follow the instructions in its README. You'll be up and running in no time!\n\n## Important Notes:\n\n1. API Usage Limits: Google may have usage limits and associated costs for the Gemini API. Be sure to review the details on their website.\n2. Responsible AI: Please use the Gemini API responsibly and ethically. Avoid generating harmful or misleading content.\n3. Feedback Welcome: We value your input! If you encounter issues, have suggestions, or want to share your creations, please open an issue or pull request.\n\n## Current Projects\n\n| Name                                            | Description                                                                                                                                                                                                                                                                                   | Tools                                                                                                                                                                                                        |\n| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| [Multimodal Embeddings](/multimodal-embeddings) | Using Gemini's new Multimodal Embeddings API, we'll explore high dimensional embedding space of text, images, and videos.                                                                                                                                                                     | [Multimodal Embeddings API](https://cloud.google.com/vertex-ai/generative-ai/docs/embeddings/get-multimodal-embeddings), [Firestore Vector Search](https://firebase.google.com/docs/firestore/vector-search) |\n| [Gemini Video Scrubber](/video-scrubber)        | \"GVS\" is a prototype that uses Gemini's multimodal video understanding capabilities to create timestamped summaries of videos with a simple UI to play those timestamps back in sequence, giving you the ability to quickly scan videos for interesting moments, common occurences, and more! | Multimodal Gemini, File API, Caching                                                                                                                                                                         |\n| [Voice Cursor](/voice-cursor)                    | An experimental text editor that lets you highlight phrases and instantly hear them spoken by Gemini 2.0 in different expressive styles. Simply select text, choose a tone, and hear AI-generated speech with customizable prompts. | [Gemini 2.0 Native Audio Output](https://ai.google.dev/gemini-api/docs/models/gemini-v2#speech-generation-early-accessallowlist)                                                                                                                                                                   |\n| [Image to Code](/image-to-code)                  | An experimental site that uses Gemini 2.0 Flash to turn an image --> into a creative code sketch (p5.js). | [Gemini 2.0 Flash](https://deepmind.google/technologies/gemini/flash/) |\n\n## Experiments for all\n\nThis is an experiment, not an official Google product. We'll do our best to support and maintain this experiment but your mileage may vary.\n\nWe encourage open sourcing projects as a way of learning from each other. Please respect our and other creators' rights, including copyright and trademark rights when present, when sharing these works and creating derivative work. If you want more info on Google's policy, you can find that [here](https://www.google.com/permissions/).\n"
  },
  {
    "path": "image-to-code/.gcloudignore",
    "content": "# This file specifies files that are *not* uploaded to Google Cloud\n# using gcloud. It follows the same syntax as .gitignore, with the addition of\n# \"#!include\" directives (which insert the entries of the given .gitignore-style\n# file at that point).\n#\n# For more information, run:\n#   $ gcloud topic gcloudignore\n#\n.gcloudignore\n# If you would like to upload your .git directory, .gitignore file or files\n# from your .gitignore file, remove the corresponding line\n# below:\n.git\n.gitignore\n\n# Node.js dependencies:\nnode_modules/"
  },
  {
    "path": "image-to-code/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.*\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/versions\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n.pnpm-debug.log*\n\n# env files (can opt-in for committing if needed)\n.env*\n.env.local\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n\n# app yaml\n*.yaml\napp.yaml\n\n"
  },
  {
    "path": "image-to-code/CONTRIBUTING.md",
    "content": "# How to contribute\n\nWe'd love to accept your patches and contributions to this project.\n\n## Before you begin\n\nSign our Contributor License Agreement\nContributions to this project must be accompanied by a Contributor License Agreement (CLA). You (or your employer) retain the copyright to your contribution; this simply gives us permission to use and redistribute your contributions as part of the project.\n\nIf you or your current employer have already signed the Google CLA (even if it was for a different project), you probably don't need to do it again.\n\nVisit https://cla.developers.google.com/ to see your current agreements or to sign a new one.\n\n### Review our community guidelines\nThis project follows Google's Open Source Community Guidelines.\n\n## Contribution process\n\n###Code reviews\nAll submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose. Consult GitHub Help for more information on using pull requests."
  },
  {
    "path": "image-to-code/LICENSE",
    "content": "\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License."
  },
  {
    "path": "image-to-code/README.md",
    "content": "# Image to Code Generator 🎨\n\n![image-to-code-sketch](./readme/smoke.gif)\n\nGet started with [p5js](https://p5js.org/) sketches using [Gemini 2.0 Flash](https://deepmind.google/technologies/gemini/flash/). Upload any photo, and this web app uses [Gemini 2.0 Flash](https://deepmind.google/technologies/gemini/flash/) to generate a [p5.js](https://p5js.org/) sketch that captures the essence of the image in an interactive way.\n\n## Getting Started\n\n### 1. Clone this repository and install dependencies:\n\n```\ngit clone https://github.com/googlecreativelab/gemini-demos/\ncd image-to-code-sketch\nnpm install\n```\n\n### 2. Create a .env.local file with your AI Studio API key:\n\nGet your API key from [Google AI Studio](https://aistudio.google.com/apikey). And create a `.env` file in the root of the project.\n\n```\nNEXT_PUBLIC_GEMINI_API_KEY=your_api_key_here\n```\n\n### 3. Start the development server:\n\n```\nnpm run dev\n```\n\nOpen http://localhost:3000 and start uploading images!\n\n## Prompt Transparency\n\nThe prompt to transform images into p5js sketches can be found in `pages/index.js`.\n\n```\nYou are a creative coding expert who turns images into clever code sketches using p5js. A user will upload an image and you will generate a interactive p5js sketch that represents the image. The code sketch always has some sort of interactive element that connects to the nature of the object in the real world.\n\n## EXAMPLES\n\nHere are some examples of what I mean by how the type of image could be turned into a clever creative coding sketch to capture the essence of the image.\n- A photo of birds --> a boids flocking algorithm sketch where the boids follow your mouse \n- A photo of a tree --> a recursive fractal tree that grows as you move your mouse up and down\n- A photo of a pond --> a sketch that has a ripple animation on mouse click\n- A photo of a wristwatch --> beautiful functioning clock that accesses system time and displays it like the wristwatch\n- A photo of a lamp --> a sketch of the lamp, but when you click the screen the lamp turns on and off\n- A photo of a zipper --> a sketch representing the shapes of the zipper, and when you move your mouse up and down the zipper opens and closes like a real zipper\n\n## PROCESS\n\nTo achieve creating this sketch, you reflect and meditate on the nature of the object BEFORE picking an algorithmic approach to represent the image. You are an agent that is thoughtful, clever, delightful, and playful.\n\nBefore you start, think about the image and the best way to represent it in p5js.\n\n1. Describe the behavioral properties of the image. List some ways it behaves in the real world or some patterns it exhibits. Describe the colors and vibe of the image as well. \n\n2. Given the behavorial properties of the image, identify a common creative coding algorithm that can be paired up to this image to make a delightful p5js sketch.\n\n3. State the bounding boxes of the important parts of the composition of the photo. We will need to use these bounding boxes to make sure our composition of our sketch resembles the composition of the photo uploaded. Our sketch's composition needs to resemble the composition of the uploaded photo.\n\n4. Implement a algorithm in p5js, using the properties of the image described earlier. Use either mouseMoved() or mouseClicked() to make it interactive. Generate a SINGLE, COMPLETE code snippet. We parse out the response you generate, so we should have only ONE code snippet that incorporates all of the information from steps 1 (behavioral description), 2 (creative coding algorithm to bring this to life), 3 (bounding boxes to preserve compositional integrity).\n\n## EXECUTION\n\nComplete all of these steps. When you write your code, be sure to leave clear comments to describe the different parts of the code and what you are doing. \n\nDo not EVER try to load in external images or any other libraries. Everything must be self contained in the one file and code snippet.\n\nAnd don't be too verbose.\n```\n\n## Credits\n\nCode by [Trudy Painter](https://www.trudy.computer/). Design by [Jose Guizar](https://joseguizar.com/).\n\n## Contributing 🤝\n\nContributions are welcome! See the `CONTRIBUTING.md` file for more information.\n\n## Disclaimer\n\nThis is an experiment showcasing Gemini 2.0's capabilities, not an official Google product. We'll do our best to support and maintain this experiment but your mileage may vary. We encourage open sourcing projects as a way of learning from each other. Please respect our and other creators' rights, including copyright and trademark rights when present, when sharing these works and creating derivative work. If you want more info on Google's policy, you can find that [here](https://www.google.com/permissions/).\n\n## License\n\nLicensed under the Apache-2.0 license."
  },
  {
    "path": "image-to-code/components/CodePreview.js",
    "content": "/**\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport React, { useState } from 'react';\nimport { Code2, Play, Copy, Check, MessageCircle } from 'lucide-react';\nimport Editor from '@monaco-editor/react';\nimport ReactMarkdown from 'react-markdown';\nimport remarkGfm from 'remark-gfm';\nimport ToggleButton from './ToggleButton';\n\nconst CodePreview = ({ output, onCodeChange, fullResponse }) => {\n  const [showCode, setShowCode] = useState(false);\n  const [showReasoning, setShowReasoning] = useState(false);\n  const [isCopied, setIsCopied] = useState(false);\n\n  const handleCopy = async () => {\n    try {\n      await navigator.clipboard.writeText(output.code);\n      setIsCopied(true);\n      setTimeout(() => setIsCopied(false), 2000);\n    } catch (err) {\n      console.error('Failed to copy code:', err);\n    }\n  };\n\n  const renderSketch = (code) => {\n    // Make sure we're working with a string\n    const codeString = typeof code === 'string' ? code : code.toString();\n    \n    const wrappedCode = codeString.includes('function setup()') ? codeString : `\n      function setup() {\n        createCanvas(500, 500);\n        ${codeString}\n      }\n\n      function draw() {\n        // Add default draw function if not present\n        if (typeof window.draw !== 'function') {\n          window.draw = function() {};\n        }\n      }\n    `;\n\n    const formattedCodeResponse = `\n      <!DOCTYPE html>\n      <html lang=\"en\">\n      <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n        <script src=\"https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.0/p5.js\"></script>\n        <title>p5.js Sketch</title>\n        <style>\n          body {\n            padding: 0;\n            margin: 0;\n            display: flex;\n            justify-content: center;\n            align-items: center;\n            min-height: 100vh;\n            overflow: hidden;\n          }\n          canvas {\n            max-width: 100% !important;\n            height: auto !important;\n          }\n        </style>\n      </head>\n      <body>\n        <script>\n          try {\n            ${wrappedCode}\n            // Immediately call setup if it hasn't been called\n            if (typeof window.setup === 'function') {\n              new p5();\n            }\n          } catch (error) {\n            console.error('Sketch error:', error);\n            document.body.innerHTML = '<div style=\"color: red; padding: 20px;\"><h3>🔴 Error:</h3><pre>' + error.message + '</pre></div>';\n          }\n        </script>\n      </body>\n      </html>\n    `;\n\n    return (\n      <div className=\"relative w-full h-[500px] bg-gray-50 rounded-lg overflow-hidden\">\n        <iframe\n          srcDoc={formattedCodeResponse}\n          title=\"p5.js Sketch\"\n          width=\"100%\"\n          height=\"100%\"\n          style={{ border: \"none\" }}\n          className=\"absolute inset-0\"\n        />\n      </div>\n    );\n  };\n\n  // Make sure we're passing the actual code string to renderSketch\n  const sketchCode = output?.code || '';\n\n  return (\n    <div className=\"mb-4 p-6 rounded-3xl bg-gray-100\">\n      <div className=\"mb-4\">\n        {showCode ? (\n          <div className=\"w-full h-[500px] rounded-lg overflow-hidden border\">\n            <Editor\n              height=\"500px\"\n              defaultLanguage=\"javascript\"\n              value={sketchCode}\n              onChange={(value) => onCodeChange(output.id, value)}\n              theme=\"light\"\n              options={{\n                minimap: { enabled: false },\n                fontSize: 12,\n                lineNumbers: 'off',\n                scrollBeyondLastLine: false,\n                automaticLayout: true,\n                tabSize: 2,\n                wordWrap: 'on',\n                padding: { top: 8, bottom: 8 }\n              }}\n            />\n          </div>\n        ) : showReasoning ? (\n          <div className=\"w-full h-[500px] rounded-lg overflow-y-auto border p-4 prose prose-xs max-w-none bg-white\">\n            <ReactMarkdown \n              remarkPlugins={[remarkGfm]}\n              className=\"text-xs text-gray-700\"\n              components={{\n                code: ({node, inline, className, children, ...props}) => (\n                  <code className={`${className} ${inline ? 'text-[0.7rem] bg-slate-50 text-slate-900 px-1.5 py-0.5 rounded border border-slate-200' : ''}`} {...props}>\n                    {children}\n                  </code>\n                ),\n                pre: ({node, children, ...props}) => (\n                  <pre className=\"text-[0.7rem] bg-slate-50 text-slate-900 p-3 rounded-md overflow-x-auto border border-slate-200\" {...props}>\n                    {children}\n                  </pre>\n                ),\n                p: ({node, children}) => (\n                  <p className=\"text-xs mb-2 text-gray-700\">{children}</p>\n                ),\n                h1: ({node, children}) => (\n                  <h1 className=\"text-sm font-bold mb-2 text-gray-900\">{children}</h1>\n                ),\n                h2: ({node, children}) => (\n                  <h2 className=\"text-xs font-bold mb-2 text-gray-900\">{children}</h2>\n                ),\n                h3: ({node, children}) => (\n                  <h3 className=\"text-xs font-semibold mb-1 text-gray-900\">{children}</h3>\n                ),\n                ul: ({node, children}) => (\n                  <ul className=\"text-xs list-disc pl-4 mb-2 text-gray-700\">{children}</ul>\n                ),\n                ol: ({node, children}) => (\n                  <ol className=\"text-xs list-decimal pl-4 mb-2 text-gray-700\">{children}</ol>\n                ),\n                li: ({node, children}) => (\n                  <li className=\"text-xs mb-1 text-gray-700\">{children}</li>\n                )\n              }}\n            >\n              {fullResponse}\n            </ReactMarkdown>\n          </div>\n        ) : (\n          renderSketch(sketchCode)\n        )}\n      </div>\n      \n      <div className=\"flex justify-between items-center mt-2\">\n        <div className=\"inline-flex rounded-full bg-gray-200 gap-1\">\n          <ToggleButton\n            icon={Play}\n            label=\"Preview\"\n            isSelected={!showCode && !showReasoning}\n            onClick={() => {\n              setShowCode(false);\n              setShowReasoning(false);\n            }}\n          />\n          <ToggleButton\n            icon={MessageCircle}\n            label=\"Reasoning\"\n            isSelected={showReasoning}\n            onClick={() => {\n              setShowCode(false);\n              setShowReasoning(true);\n            }}\n          />\n          <ToggleButton\n            icon={Code2}\n            label=\"Code\"\n            isSelected={showCode}\n            onClick={() => {\n              setShowCode(true);\n              setShowReasoning(false);\n            }}\n          />\n        </div>\n        <button\n          type=\"button\"\n          onClick={handleCopy}\n          className={`px-3.5 py-2.5 rounded-full transition-colors inline-flex text-sm  border border-gray-300 \n            items-center gap-1 ${\n            isCopied\n              ? \"bg-gray-500 text-white\"\n              : \"bg-transparent text-gray-700 hover:bg-gray-100\"\n          }`}\n        >\n          {isCopied ? (\n            <>\n              <Check size={14} />\n              Copied!\n            </>\n          ) : (\n            <>\n              <Copy size={14} />\n              Copy Code\n            </>\n          )}\n        </button>\n      </div>\n    </div>\n  );\n};\n\nexport default CodePreview; "
  },
  {
    "path": "image-to-code/components/Header.js",
    "content": "import React from 'react';\nimport { Github, Info } from 'lucide-react';\n\nconst Header = () => {\n  return (\n    <div className=\"fixed top-0 left-0 right-0 w-full bg-white p-4 z-50 \">\n      <div className=\"w-full flex justify-between items-center text-base\">\n        <div className=\"text-gray-500\">\n          <span className=\"text-black font-bold text-lg mr-2\">Image to Code</span>\n          Built with <a \n            href=\"https://ai.google.dev\" \n            target=\"_blank\" \n            rel=\"noopener noreferrer\"\n            className=\"underline hover:text-gray-800 transition-colors\"\n          >\n            Gemini 2.0\n          </a>\n        </div>\n        \n        <div className=\"flex items-center gap-3\">\n          <a\n            href=\"https://github.com/googlecreativelab/gemini-demos/tree/main/image-to-code\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"flex items-center gap-2  text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors \"\n          >\n            <Github size={18} className=\"text-gray-600\" />\n            <span>GitHub Repository</span>\n          </a>\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport default Header; "
  },
  {
    "path": "image-to-code/components/ToggleButton.js",
    "content": "import React from 'react';\n\nconst ToggleButton = ({ icon: Icon, label, isSelected, onClick }) => {\n  const baseStyles = \"inline-flex items-center gap-1 px-3.5 py-2.5 text-sm rounded-full transition-all duration-200\";\n  const selectedStyles = \"bg-black text-white\";\n  const unselectedStyles = \"bg-gray-200 text-gray-700\";\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className={`${baseStyles} ${isSelected ? selectedStyles : unselectedStyles}`}\n    >\n      {Icon && <Icon size={14} />}\n      {label}\n    </button>\n  );\n};\n\nexport default ToggleButton; "
  },
  {
    "path": "image-to-code/jsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"paths\": {\n      \"@/*\": [\"./*\"]\n    }\n  }\n}\n"
  },
  {
    "path": "image-to-code/next.config.mjs",
    "content": "/**\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/** @type {import('next').NextConfig} */\nconst nextConfig = {\n  reactStrictMode: true,\n};\n\nexport default nextConfig;\n"
  },
  {
    "path": "image-to-code/package.json",
    "content": "{\n  \"name\": \"service-image-code-2\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next dev --turbopack\",\n    \"build\": \"next build\",\n    \"start\": \"next start\",\n    \"lint\": \"next lint\"\n  },\n  \"dependencies\": {\n    \"@google/generative-ai\": \"^0.21.0\",\n    \"@monaco-editor/react\": \"^4.6.0\",\n    \"lucide-react\": \"^0.469.0\",\n    \"next\": \"15.1.3\",\n    \"react\": \"^19.0.0\",\n    \"react-dom\": \"^19.0.0\",\n    \"react-dropzone\": \"^14.3.5\",\n    \"react-markdown\": \"^9.0.3\",\n    \"remark-gfm\": \"^4.0.0\"\n  },\n  \"devDependencies\": {\n    \"@tailwindcss/typography\": \"^0.5.16\",\n    \"postcss\": \"^8\",\n    \"tailwindcss\": \"^3.4.1\"\n  }\n}\n"
  },
  {
    "path": "image-to-code/pages/_app.js",
    "content": "/**\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport \"@/styles/globals.css\";\n\nexport default function App({ Component, pageProps }) {\n  return <Component {...pageProps} />;\n}\n"
  },
  {
    "path": "image-to-code/pages/_document.js",
    "content": "/**\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Html, Head, Main, NextScript } from \"next/document\";\n\nexport default function Document() {\n  return (\n    <Html lang=\"en\">\n      <Head />\n      <body className=\"antialiased\">\n        <Main />\n        <NextScript />\n      </body>\n    </Html>\n  );\n}\n"
  },
  {
    "path": "image-to-code/pages/api/hello.js",
    "content": "/**\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// Next.js API route support: https://nextjs.org/docs/api-routes/introduction\n\nexport default function handler(req, res) {\n  res.status(200).json({ name: \"John Doe\" });\n}\n"
  },
  {
    "path": "image-to-code/pages/index.js",
    "content": "/**\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport React, { useState, useCallback, useEffect } from \"react\";\nimport { useDropzone } from \"react-dropzone\";\nimport { GoogleGenerativeAI } from \"@google/generative-ai\";\nimport { ChevronDown, Image, Upload, Settings, Send, History, ArrowRight, Pen, Layers } from \"lucide-react\";\nimport Head from \"next/head\";\nimport CodePreview from \"../components/CodePreview\";\nimport Header from '../components/Header';\n\nconst SAMPLE_IMAGES = [\n  'beeripple.jpeg',\n  'bubbles.jpeg', \n  'clock.png',\n  'flower.jpeg',\n  'garage.jpeg',\n  'sconce.jpeg',\n  'steam.jpeg',\n  'tree.png',\n  \"birds.jpeg\",\n  \"bubblemachine.png\",\n];\n\n// Initialize Gemini AI model\nconst MODEL_NAME = \"gemini-2.0-flash-exp\";\nconst genAI = new GoogleGenerativeAI(process.env.NEXT_PUBLIC_GEMINI_API_KEY);\nconst model = genAI.getGenerativeModel({ model: MODEL_NAME });\n\n// Helper function to generate code from image\nasync function generateCodeFromImage(imageBase64, prompt, userInput) {\n  const image = {\n    inlineData: {\n      data: imageBase64.split(\",\")[1],\n      mimeType: \"image/jpeg\",\n    },\n  };\n\n  const finalPrompt = userInput.trim()\n    ? `${prompt}\\n\\nUser input: ${userInput}`\n    : prompt;\n\n  const result = await model.generateContent([finalPrompt, image]);\n  const response = result.response.text();\n\n  const regex = /```(?:javascript|js)?\\s*([\\s\\S]*?)```/g;\n  const match = regex.exec(response);\n  const extractedCode = match ? match[1].trim() : response;\n\n  return {\n    fullResponse: response,\n    code: extractedCode\n  };\n}\n\nexport default function Home() {\n  const [imageBase64, setImageBase64] = useState(\"\");\n  const [outputs, setOutputs] = useState([]);\n  const [loading, setLoading] = useState(false);\n  const [hasStartedGenerating, setHasStartedGenerating] = useState(false);\n  const [selectedOutput, setSelectedOutput] = useState(null);\n  const [concurrentRequests, setConcurrentRequests] = useState(5);\n  const [showPrompt, setShowPrompt] = useState(false);\n  const [prompt, setPrompt] = useState(\"\");\n\n  // Load prompt from localStorage on initial render\n  useEffect(() => {\n    const savedPrompt = localStorage.getItem('savedPrompt');\n    if (savedPrompt) {\n      setPrompt(savedPrompt);\n    } else {\n      const defaultPrompt = `You are a creative coding expert who turns images into \n      clever code sketches using p5js. A user will upload an image and you will \n      generate a interactive p5js sketch that represents the image. \n      The code sketch always has some sort of interactive element that \n      connects to the nature of the object in the real world.\n\n      ## EXAMPLES\n\n      Here are some examples of what I mean by how the type of image could \n      be turned into a clever creative coding sketch to capture the essence of the image.\n      - A photo of birds --> a boids flocking algorithm sketch where the boids follow your mouse \n      - A photo of a tree --> a recursive fractal tree that grows as you move your mouse up and down\n      - A photo of a pond --> a sketch that has a ripple animation on mouse click\n      - A photo of a wristwatch --> beautiful functioning clock that \n      accesses system time and displays it like the wristwatch\n      - A photo of a lamp --> a sketch of the lamp, but when you click \n      the screen the lamp turns on and off\n      - A photo of a zipper --> a sketch representing the shapes of the zipper, \n      and when you move your mouse up and down the zipper opens and closes like a real zipper\n\n      ## PROCESS\n\n      To achieve creating this sketch, you reflect and \n      meditate on the nature of the object BEFORE picking an algorithmic \n      approach to represent the image. You are an agent that is thoughtful, \n      clever, delightful, and playful.\n\n      Before you start, think about the image and the best way to represent it in p5js.\n      1. Describe the behavioral properties of the image. List some ways it\n       behaves in the real world or some patterns it exhibits. Describe the \n       colors and vibe of the image as well. \n      2. Given the behavorial properties of the image, identify a common creative \n      coding algorithm that can be paired up to this image to make a delightful p5js sketch.\n      3. State the bounding boxes of the important parts of the composition \n      of the photo. We will need to use these bounding boxes to make sure our \n      composition of our sketch resembles the composition of the photo uploaded. \n      Our sketch's composition needs to resemble the composition of the uploaded photo.\n      4. Implement a algorithm in p5js, using the properties of the image described \n      earlier. Use either mouseMoved() or mouseClicked() to make it interactive. \n      Generate a SINGLE, COMPLETE code snippet. We parse out the response you generate, \n      so we should have only ONE code snippet that incorporates all of the information \n      from steps 1 (behavioral description), 2 (creative coding algorithm to bring this to life), \n      3 (bounding boxes to preserve compositional integrity).\n\n      ## EXECUTION\n\n      Complete all of these steps. When you write your code, be sure to leave clear \n      comments to describe the different parts of the code and what you are doing. \n      Do not EVER try to load in external images or any other libraries. \n      Everything must be self contained in the one file and code snippet.\n      And don't be too verbose.`\n      \n      \n      \n      .trim();\n      setPrompt(defaultPrompt);\n      localStorage.setItem('savedPrompt', defaultPrompt);\n    }\n  }, []);\n\n  // Save prompt to localStorage whenever it changes\n  useEffect(() => {\n    if (prompt) {\n      localStorage.setItem('savedPrompt', prompt);\n    }\n  }, [prompt]);\n\n  const [showSamples, setShowSamples] = useState(false);\n  const [selectedSample, setSelectedSample] = useState(null);\n  const [userInput, setUserInput] = useState(\"\");\n  const [imageDetails, setImageDetails] = useState(null);\n\n  const onDrop = useCallback((acceptedFiles) => {\n    const file = acceptedFiles[0];\n    const reader = new FileReader();\n\n    reader.onload = (event) => {\n      const img = document.createElement(\"img\");\n      img.src = event.target.result;\n\n      img.onload = () => {\n        const canvas = document.createElement(\"canvas\");\n        const scaleFactor = 512 / img.width;\n        canvas.width = 512;\n        canvas.height = img.height * scaleFactor;\n        const ctx = canvas.getContext(\"2d\");\n        ctx.drawImage(img, 0, 0, canvas.width, canvas.height);\n        setImageBase64(canvas.toDataURL());\n        setImageDetails({\n          name: file.name,\n          size: `${(file.size / 1024).toFixed(2)}kB`,\n          type: file.type\n        });\n      };\n    };\n\n    reader.readAsDataURL(file);\n  }, []);\n\n  const { getRootProps, getInputProps, isDragActive } = useDropzone({\n    onDrop,\n    accept: \"image/*\",\n  });\n\n  const generateCode = async () => {\n    if (!imageBase64) return;\n\n    setLoading(true);\n    setHasStartedGenerating(true);\n    setOutputs([]);\n    try {\n      const requests = Array(concurrentRequests)\n        .fill()\n        .map(() => generateCodeFromImage(imageBase64, prompt, userInput));\n      \n      const results = await Promise.all(requests);\n      setOutputs(results.map((result, index) => ({\n        id: index + 1,\n        code: result.code,\n        fullResponse: result.fullResponse\n      })));\n    } catch (error) {\n      console.error(\"Error generating code:\", error);\n    } finally {\n      setLoading(false);\n    }\n  };\n\n  const renderSketch = (code) => {\n    const formattedCodeResponse = `\n      <!DOCTYPE html>\n      <html lang=\"en\">\n      <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=512, initial-scale=1.0\">\n        <script src=\"https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.0/p5.js\"></script>\n        <title>p5.js Sketch</title>\n        <style> body {padding: 0; margin: 0;} </style>\n      </head>\n      <body>\n      <script>\n        window.onerror = function(message, source, lineno, colno, error) {\n          document.body.innerHTML += '<h3>🔴Error:</h3><pre>' + message + '</pre>';\n        };\n        ${code}\n      </script>\n      </body>\n      </html>\n    `;\n\n    return (\n      <iframe\n        srcDoc={formattedCodeResponse}\n        title=\"p5.js Sketch\"\n        width=\"100%\"\n        height=\"300\"\n        style={{ border: \"none\" }}\n      />\n    );\n  };\n\n\n\n  const handleCodeChange = (id, newCode) => {\n    setOutputs((prevOutputs) =>\n      prevOutputs.map((output) =>\n        output.id === id ? { ...output, code: newCode } : output\n      )\n    );\n  };\n\n  const handleSampleSelect = async (imageName) => {\n    setSelectedSample(imageName);\n    try {\n      const response = await fetch(`/samples/${imageName}`);\n      const blob = await response.blob();\n      const reader = new FileReader();\n\n      reader.onload = (event) => {\n        const img = document.createElement(\"img\");\n        img.src = event.target.result;\n\n        img.onload = () => {\n          const canvas = document.createElement(\"canvas\");\n          const scaleFactor = 512 / img.width;\n          canvas.width = 512;\n          canvas.height = img.height * scaleFactor;\n          const ctx = canvas.getContext(\"2d\");\n          ctx.drawImage(img, 0, 0, canvas.width, canvas.height);\n          setImageBase64(canvas.toDataURL());\n        };\n      };\n\n      reader.readAsDataURL(blob);\n    } catch (error) {\n      console.error('Error loading sample image:', error);\n    }\n  };\n\n  return (\n    <>\n      <Head>\n        <title>Image to Code</title>\n      </Head>\n      <div className=\"h-screen max-h-screen bg-white flex items-center justify-center overflow-y-hidden tracking-[-0.005em]\">\n        <Header />\n\n        <div className=\"w-full h-full max-h-full overflow-hidden bg-white\">\n          <div className={`flex flex-col md:flex-row gap-0 max-w-6xl mx-auto py-0 h-full transition-all duration-500 ${!hasStartedGenerating ? 'justify-center' : ''}`}>\n            <div className={`flex-1 h-full overflow-y-auto py-20 px-3 transition-all duration-500 ${!hasStartedGenerating ? 'md:max-w-2xl mx-auto' : ''}`}>\n              <section className=\"flex flex-col bg-gray-100 rounded-2xl p-4\">\n                <div\n                  {...getRootProps()}\n                  className={`border-2 border-dashed bg-gray-100 rounded-2xl m-4 min-h-96 h-fit flex \n                flex-col items-center justify-center cursor-pointer hover:border-gray-400 transition-colors ${imageBase64 ? 'border-none' : 'border-gray-300'}`}\n                >\n                  <input {...getInputProps()} />\n                  {imageBase64 ? (\n                    <img\n                      src={imageBase64}\n                      alt=\"Uploaded\"\n                      className=\"max-h-full max-w-full object-contain rounded-2xl\"\n                    />\n                  ) : (\n                    <>\n                      <Upload className=\"w-12 h-12 text-gray-400 mb-4\" />\n                      <p className=\"text-gray-400\">\n                        {isDragActive\n                          ? \"Drop the image here\"\n                          : \"Drag & drop an image here, or click to select one\"}\n                      </p>\n                    </>\n                  )}\n                </div>\n                <div className=\"max-w-xl mb-4\">\n                  <div className=\"flex overflow-x-auto gap-2 py-1 mx-4\">\n                    {SAMPLE_IMAGES.map((image) => (\n                      <button\n                        key={image}\n                        type=\"button\"\n                        onClick={() => handleSampleSelect(image)}\n                        className={`flex-shrink-0 w-14 h-14 bg-white rounded-lg hover:scale-110 transition-all ${selectedSample === image ? 'border-blue-500 ring-2 ring-blue-200' : 'border-gray-300'\n                          }`}\n                      >\n                        <img\n                          src={`/samples/${image}`}\n                          alt={image}\n                          className=\"w-full h-full object-cover rounded-lg\"\n                        />\n                      </button>\n                    ))}\n                  </div>\n                </div>\n              </section>\n              <section className=\"mt-4 space-y-4 bg-gray-100 rounded-2xl p-4\">\n                <div>\n                  <button\n                    type=\"button\"\n                    onClick={() => setShowSamples(!showSamples)}\n                    className=\"flex items-center gap-2 text-sm text-gray-600 hover:text-gray-800 transition-colors\"\n                  >\n                    {/* <Settings size={16} /> */}\n                    <span className=\"font-bold\">Advanced</span>\n                    <ChevronDown\n                      size={16}\n                      className={`transform transition-transform ${showSamples ? 'rotate-180' : ''\n                        }`}\n                    />\n                  </button>\n\n                  {showSamples && (\n                    <div className=\"my-2 rounded-lg\">\n                      <div className=\"space-y-2\">\n                        <div className=\"flex items-center justify-between\">\n                          <div className=\"flex items-center gap-2\">\n                            <Layers size={14} className=\"text-gray-600\" />\n                            <label htmlFor=\"concurrent-requests\" className=\"text-sm font-medium text-gray-700\">\n                              Concurrent Requests: {concurrentRequests}\n                            </label>\n                          </div>\n                          <input\n                            id=\"concurrent-requests\"\n                            type=\"range\"\n                            min=\"1\"\n                            max=\"10\"\n                            value={concurrentRequests}\n                            onChange={(e) => setConcurrentRequests(Number(e.target.value))}\n                            className=\"w-1/2\"\n                          />\n                        </div>\n\n                        <button\n                          type=\"button\"\n                          onClick={() => setShowPrompt(!showPrompt)}\n                          className=\"flex items-center gap-2 text-sm text-gray-600 hover:text-gray-800 transition-colors\"\n                        >\n                          <Pen size={14} />\n                          <span>Edit System Prompt</span>\n                          <ChevronDown\n                            size={16}\n                            className={`transform transition-transform ${showPrompt ? 'rotate-180' : ''}`}\n                          />\n                        </button>\n\n                        {showPrompt && (\n                          <textarea\n                            value={prompt}\n                            onChange={(e) => setPrompt(e.target.value)}\n                            className=\"w-full h-64 p-2 border rounded-lg font-mono text-sm mt-2\"\n                            placeholder=\"Enter your prompt here...\"\n                          />\n                        )}\n                      </div>\n                    </div>\n                  )}\n                </div>\n\n              </section>\n              <section className=\"mt-4\">\n                <button\n                  type=\"button\"\n                  onClick={generateCode}\n                  className=\"px-4 py-4 bg-gray-800 text-white rounded-2xl mb-8\n                  hover:bg-gray-900  transition-colors w-full disabled:bg-gray-300 disabled:cursor-not-allowed\n                  flex items-center justify-center gap-2 font-bold\"\n                  disabled={!imageBase64 || loading}\n                >\n                  {/* <Send size={16} className={loading ? 'opacity-50' : ''} /> */}\n                  <span>{loading ? \"Generating...\" : `Generate ${concurrentRequests} Code Snippet${concurrentRequests > 1 ? 's' : ''}`}</span>\n\n                </button>\n              </section>\n            </div>\n\n            {hasStartedGenerating && (\n              <div className=\"flex-1 h-full overflow-y-scroll py-20 px-3 animate-slide-in\">\n                {loading ? (\n                  // Loading skeletons for code previews\n                  Array(concurrentRequests).fill().map((_, index) => (\n                    <div key={`skeleton-${index}`} className=\"mb-4 p-6 rounded-3xl bg-gray-100 animate-pulse\">\n                      <div className=\"w-full h-[500px] bg-gray-200 rounded-lg mb-4\" />\n                      <div className=\"flex justify-between items-center\">\n                        <div className=\"h-10 w-32 bg-gray-200 rounded-full\" />\n                        <div className=\"h-10 w-24 bg-gray-200 rounded-full\" />\n                      </div>\n                    </div>\n                  ))\n                ) : (\n                  outputs.map((output) => (\n                    <CodePreview\n                      key={output.id}\n                      output={output}\n                      onCodeChange={handleCodeChange}\n                      fullResponse={output.fullResponse}\n                    />\n                  ))\n                )}\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n    </>\n  );\n};\n\n"
  },
  {
    "path": "image-to-code/postcss.config.mjs",
    "content": "/**\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/** @type {import('postcss-load-config').Config} */\nconst config = {\n  plugins: {\n    tailwindcss: {},\n  },\n};\n\nexport default config;\n"
  },
  {
    "path": "image-to-code/styles/globals.css",
    "content": "/**\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n:root {\n  --background: #ffffff;\n  --foreground: #171717;\n}\n\n@media (prefers-color-scheme: dark) {\n  :root {\n    --background: #0a0a0a;\n    --foreground: #ededed;\n  }\n}\n\nbody {\n  color: var(--foreground);\n  background: var(--background);\n  font-family: Arial, Helvetica, sans-serif;\n}\n"
  },
  {
    "path": "image-to-code/tailwind.config.js",
    "content": "/**\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/** @type {import('tailwindcss').Config} */\nmodule.exports = {\n  content: [\n    './pages/**/*.{js,ts,jsx,tsx,mdx}',\n    './components/**/*.{js,ts,jsx,tsx,mdx}',\n    './app/**/*.{js,ts,jsx,tsx,mdx}',\n  ],\n  theme: {\n    extend: {\n      keyframes: {\n        'fade-in-out': {\n          '0%': { opacity: '0', transform: 'translateY(-10px)' },\n          '10%': { opacity: '1', transform: 'translateY(0)' },\n          '90%': { opacity: '1' },\n          '100%': { opacity: '0' }\n        },\n        'slide-in': {\n          '0%': { transform: 'translateX(100%)' },\n          '100%': { transform: 'translateX(0)' }\n        }\n      },\n      animation: {\n        'fade-in-out': 'fade-in-out 5s ease-in-out',\n        'slide-in': 'slide-in 0.5s ease-out'\n      },\n      typography: {\n        xs: {\n          css: {\n            fontSize: '0.75rem',\n            lineHeight: '1.2',\n            color: '#374151',\n            p: {\n              marginBottom: '0.5rem',\n            },\n            h1: {\n              fontSize: '1rem',\n              marginBottom: '0.75rem',\n            },\n            h2: {\n              fontSize: '0.875rem',\n              marginBottom: '0.5rem',\n            },\n            h3: {\n              fontSize: '0.75rem',\n              marginBottom: '0.5rem',\n            },\n            'code': {\n              fontSize: '0.7rem !important',\n              padding: '0.1rem 0.2rem',\n              backgroundColor: '#f8fafc',\n              color: '#0f172a !important',\n              borderRadius: '0.25rem',\n              border: '1px solid #e2e8f0',\n            },\n            'pre': {\n              fontSize: '0.7rem !important',\n              padding: '0.75rem',\n              marginBottom: '0.75rem',\n              backgroundColor: '#f8fafc',\n              color: '#0f172a',\n              border: '1px solid #e2e8f0',\n              code: {\n                fontSize: '0.7rem !important',\n                backgroundColor: 'transparent',\n                padding: 0,\n                border: 'none',\n                color: '#0f172a',\n              }\n            },\n            'ul': {\n              marginBottom: '0.75rem',\n            },\n            'ol': {\n              marginBottom: '0.75rem',\n            },\n            'li': {\n              marginBottom: '0.25rem',\n            }\n          }\n        }\n      }\n    },\n  },\n  plugins: [\n    require('@tailwindcss/typography'),\n  ],\n} "
  },
  {
    "path": "image-to-code/tailwind.config.mjs",
    "content": "/**\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/** @type {import('tailwindcss').Config} */\nexport default {\n  content: [\n    \"./pages/**/*.{js,ts,jsx,tsx,mdx}\",\n    \"./components/**/*.{js,ts,jsx,tsx,mdx}\",\n    \"./app/**/*.{js,ts,jsx,tsx,mdx}\",\n  ],\n  theme: {\n    extend: {\n      colors: {\n        background: \"var(--background)\",\n        foreground: \"var(--foreground)\",\n      },\n    },\n  },\n  plugins: [],\n};\n"
  },
  {
    "path": "multimodal-embeddings/.gcloudignore",
    "content": "# This file specifies files that are *not* uploaded to Google Cloud\n# using gcloud. It follows the same syntax as .gitignore, with the addition of\n# \"#!include\" directives (which insert the entries of the given .gitignore-style\n# file at that point).\n#\n# For more information, run:\n#   $ gcloud topic gcloudignore\n#\n.gcloudignore\n# If you would like to upload your .git directory, .gitignore file or files\n# from your .gitignore file, remove the corresponding line\n# below:\n.git\n.gitignore\n\n# Node.js dependencies:\nnode_modules/\n"
  },
  {
    "path": "multimodal-embeddings/.npmrc",
    "content": "engine-strict=true\n"
  },
  {
    "path": "multimodal-embeddings/.prettierignore",
    "content": "# Ignore files for PNPM, NPM and YARN\npnpm-lock.yaml\npackage-lock.json\nyarn.lock\n"
  },
  {
    "path": "multimodal-embeddings/.prettierrc",
    "content": "{\n\t\"useTabs\": true,\n\t\"singleQuote\": true,\n\t\"trailingComma\": \"none\",\n\t\"printWidth\": 100,\n\t\"plugins\": [\n\t\t\"prettier-plugin-svelte\",\n\t\t\"prettier-plugin-tailwindcss\"\n\t],\n\t\"tabWidth\": 2,\n\t\"overrides\": [\n\t\t{\n\t\t\t\"files\": \"*.svelte\",\n\t\t\t\"options\": {\n\t\t\t\t\"parser\": \"svelte\"\n\t\t\t}\n\t\t}\n\t]\n}"
  },
  {
    "path": "multimodal-embeddings/README.md",
    "content": "> **This repo is provided _as-is_ for reference**, since you need a Firebase project with at least one collection that contains embeddings, and the proper APIs enabled (with billing) to generate embeddings.\n\n> **NEW!** While not _fully_ functional, the repo now includes exported Firebase emulator data so you can get up and running quicker, follow along below!\n\n# Multimodal Embeddings Demo\n\n[Check out this video](https://x.com/labsdotgoogle/status/1838686949835706607) on the [Labs.google](https://labs.google) X account to see a quick overview of the project:\n\n[![Overview Video on X](https://raw.githubusercontent.com/googlecreativelab/gemini-demos/refs/heads/main/multimodal-embeddings/static/screenshot.png)](https://x.com/labsdotgoogle/status/1838686949835706607)\n\n> Unsure what embeddings are? [Here's an old video](https://www.youtube.com/watch?v=wvsE8jm1GzE) we made about visualizing embeddings that does a good job explaining the basics. Learn more about Multimodal Embeddings in the [Cloud docs here](https://cloud.google.com/vertex-ai/generative-ai/docs/embeddings/get-multimodal-embeddings).\n\nThis repo represents most of the code used in Khayti's \"Personal Search\" demo in the video above, with two endpoints that can be used to explore your own embeddings:\n\n1. `/search` - where we use Firebase Vector Search to find the closest embeddings to _both_ text and image input (image search!), and\n2. `/viz` - bonus! where we use [UMAP](https://pair-code.github.io/understanding-umap/) to reduce the dimensions of our embeddings to visualize their relationships in 3D.\n\nThe app is built with Firebase and SvelteKit, which uses Threlte for a declarative 3D rendering engine built on top of THREE.js (for `/viz`).\n\n## Get Started\n\nWe'll be able to test quickly with some exported Firebase Emulator data so let's dive right in:\n\n1. [Create a new project](https://firebase.google.com/docs/web/setup) in Firebase that has Functions, Firestore and Storage enabled.\n2. Run `firebase init` within this folder, enabling Firestore, Storage and Emulators to quickly be able to test with emulator data.\n3. Update `/src/lib/consts.ts` with your firebase project info.\n4. `npm i && npm run dev:emulate` should now work, building the site and starting the emulators. You can test this by visiting [`http://localhost:5173/viz`](http://localhost:5173/viz), which should load in the provided 'Weater' dataset.\n5. Optional - Get a Gemini API key for any Gemini-related extra tasks.\n\n> `firebase init` creates some files, like `firebase.rc` and some rules for Firestore and Firebase Storage. If you run into errors like the 'weather' images not loading in `/viz`, it could be the storage rules being set to 'false' as opposed to something that allows them to be loaded. Learn more in the `Visualizing` section below.\n\n### Firebase Cloud Function for Embedding Generation\n\nWe've included a little bonus here in `/fb/functions` that can _automatically_ generate embeddings for files uploaded to your Cloud Bucket. It also generates collections based on the folder structure of the uploads.\n\nThis was great for our team when we were prototyping with the API since anyone could just create a new folder, upload images, and have it available in the UI for exploration.\n\nCheck out [`/fb/README.md`](fb/README.md) for more info.\n\n> Note: you _can_ get this to run in the emulator as well, but its out of scope for this already too-long doc.\n\nThere are a bunch of utility methods and components as well in `/src/lib`, but most importantly used by `/search` and `/viz` is `/src/lib/components/CollectionList.svelte` which will attempt to pull in any Firestore Collections created by the function in `/fb/functions` (if you choose to use that).\n\n### Create embeddings yourself\n\nRead through the [Multimodal Embeddings documentation](https://cloud.google.com/vertex-ai/generative-ai/docs/embeddings/get-multimodal-embeddings) as our code in `/src/lib/embedder.ts` implements this almost exactly. You'll need to add your project name to this file as well for it to work. You send text, images, or video, and receive back a vector that needs to be store in Firebase.\n\n> `embedder.ts` is also called directly from the Function in `/fb/functions/index.ts` so we didn't have to have two copies of the code in each sub module.\n\n### I have embeddings, now what?\n\nNext, read through the [Firebase Vector Search docs](https://firebase.google.com/docs/firestore/vector-search).\n\nYou'll store each image embedding in [a Firestore document](https://firebase.google.com/docs/firestore/vector-search#write_operation_with_a_vector_embedding) via `FieldValue.vector()`, and once you've done this for all your embeddings, you'll need to [create an index of that collection](https://firebase.google.com/docs/firestore/vector-search#create_and_manage_vector_indexes) so the Vector Search can work.\n\nIf you have _not_ created an index, but go to `/search` and try to search your collection, you'll get a handy error that gives you the exact code to run in order to start that, something like this:\n\n```bash\ngcloud firestore indexes composite create \\\n--collection-group=collection-group \\\n--query-scope=COLLECTION \\\n--field-config field-path=vector-field,vector-config='vector-configuration' \\\n--database=your-database-id\n```\n\nOnce indexed, your collection can be searched!\n\n### Searching\n\nAgain, everything in this repo follows the [Firebase Vector Search docs](https://firebase.google.com/docs/firestore/vector-search) closely, and for searching, we're [making a nearest-neighbor query](https://firebase.google.com/docs/firestore/vector-search#make_a_nearest-neighbor_query).\n\nConceptually though, you're doing two things:\n\n1. Embedding _the query_ in order to place it within the same space as your collections embeddings, then\n2. Doing a nearest-neighbor lookup to find any results that are nearby to your query.\n\nAnd since we're using the Multimodal Embeddings API, your query can be text, an image, or a video.\n\n**Important Note** - you'll notice that we also have a file `/src/lib/cloud-firebase.ts`. At time of creating this demo, the actual Search APIs only resided in `@google-cloud/firestore` on NPM, which is separate from the normal Firebase web APIs in npm's `firebase` that are used elsewhere in the app.\n\n### Visualizing\n\n`/viz` takes your Firestore collections and attempts to plot them in 3D using UMAP, an API similar to T-SNE but much faster (and just as non-deterministic).\n\n> [Learn more about UMAP here.](https://pair-code.github.io/understanding-umap/)\n\n<figure>\n  <img src=\"static/viz.png\"/>\n  <figcaption>/viz using the public weather dataset mentioned above</figcaption>\n</figure>\n\nIt was a WIP that was never fully completed but should get you 90% of the way there. What's important to note is lowering dimensions on embeddings inherently loses information, so while its a really nice way to visualize things it shouldn't be considered an exact representation of the embeddings (which are 1408 dimensions).\n\n## Experiments for all\n\nThis is an experiment, not an official Google product. We’ll do our best to support and maintain this experiment but your mileage may vary.\n\nWe encourage open sourcing projects as a way of learning from each other. Please respect our and other creators’ rights, including copyright and trademark rights when present, when sharing these works and creating derivative work. If you want more info on Google's policy, you can find that [here](https://www.google.com/permissions/).\n"
  },
  {
    "path": "multimodal-embeddings/app.yaml",
    "content": "runtime: nodejs20\nservice: mm-embed\ninstance_class: F2\ndefault_expiration: '0s'\n\n# handlers element provides a list of URL patterns and descriptions of how they should be handled.\n# https://cloud.google.com/appengine/docs/standard/python/config/appref#handlers_element\nhandlers:\n  - url: /static\n    static_dir: static\n\n  - url: /.*\n    secure: always\n    redirect_http_response_code: 301\n    script: auto\n"
  },
  {
    "path": "multimodal-embeddings/components.json",
    "content": "{\n\t\"$schema\": \"https://shadcn-svelte.com/schema.json\",\n\t\"style\": \"new-york\",\n\t\"tailwind\": {\n\t\t\"config\": \"tailwind.config.ts\",\n\t\t\"css\": \"src/app.css\",\n\t\t\"baseColor\": \"stone\"\n\t},\n\t\"aliases\": {\n\t\t\"components\": \"$lib/components\",\n\t\t\"utils\": \"$lib/utils\"\n\t},\n\t\"typescript\": true\n}"
  },
  {
    "path": "multimodal-embeddings/cors.json",
    "content": "[\n\t{\n\t\t\"origin\": [\"*\"],\n\t\t\"method\": [\"GET\"],\n\t\t\"maxAgeSeconds\": 3600\n\t}\n]\n"
  },
  {
    "path": "multimodal-embeddings/emulator-export/firebase-export-metadata.json",
    "content": "{\n  \"version\": \"13.20.2\",\n  \"firestore\": {\n    \"version\": \"1.19.8\",\n    \"path\": \"firestore_export\",\n    \"metadata_file\": \"firestore_export/firestore_export.overall_export_metadata\"\n  },\n  \"storage\": {\n    \"version\": \"13.20.2\",\n    \"path\": \"storage_export\"\n  }\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/blobs/b2e7394a-32cb-45ca-abea-73d0b2df628f",
    "content": "--boundary\r\nContent-Type: application/json\r\n\r\n{\"contentType\":\"text/plain\"}\r\n--boundary\r\nContent-Type: text/plain\r\n\r\n--boundary--\r\n"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/blobs/dd83acba-c2c2-471f-96e6-02a7564ccdf2",
    "content": "--boundary\r\nContent-Type: application/json\r\n\r\n{\"contentType\":\"text/plain\"}\r\n--boundary\r\nContent-Type: text/plain\r\n\r\n--boundary--\r\n"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/buckets.json",
    "content": "{\n\t\"buckets\": [\n\t\t{\n\t\t\t\"id\": \"mm-demo.appspot.com\"\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/0001871f-3e7a-461d-b428-92efcd1ce0fd.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0595.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214227,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"dc68f214-814b-4152-a8c3-af9a1a0781f3\"\n  ],\n  \"etag\": \"36BWtqv7vexAC1OiGqyOdxIRnjI\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.227Z\",\n  \"updated\": \"2024-10-02T01:56:54.227Z\",\n  \"size\": 7263,\n  \"md5Hash\": \"cbJfY8NsRWjyZ2aHtRfFFw==\",\n  \"crc32c\": \"381430859\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/03da0657-bb18-42fd-9831-7e48c0b723cf.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_106.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214209,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"5ef0c5d1-0d7e-405c-8e30-06ba8079c1ee\"\n  ],\n  \"etag\": \"kCworTTvOTm2vjrfrKhLe6ZW0vw\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.209Z\",\n  \"updated\": \"2024-10-02T01:56:54.209Z\",\n  \"size\": 13164,\n  \"md5Hash\": \"DucB88Cx3AmGflsnemuA0g==\",\n  \"crc32c\": \"75427758\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/03f9df1b-0c0d-4014-8a7d-3a5eccad3faf.json",
    "content": "{\n  \"name\": \"mmembed/weather/103.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213880,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"c0caebb1-4dca-4040-862b-08bd67c78a16\"\n  ],\n  \"etag\": \"k8SJ9dRJMXQgbOcKCs8Tognnzvo\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.880Z\",\n  \"updated\": \"2024-10-02T01:56:53.880Z\",\n  \"size\": 16411,\n  \"md5Hash\": \"iUfHvP2JqOoxdXfg8kbd+w==\",\n  \"crc32c\": \"1972598782\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/04b454df-1216-4ec1-84fb-f59afd85fe90.json",
    "content": "{\n  \"name\": \"mmembed/weather/6101.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214163,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"0b9617f7-9b67-4554-89fe-010e4bc2175d\"\n  ],\n  \"etag\": \"KIL8zGyp8+VK/srSl4D0BSElYRM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.164Z\",\n  \"updated\": \"2024-10-02T01:56:54.164Z\",\n  \"size\": 30704,\n  \"md5Hash\": \"VD1ZhLj58KUNotQINc+dAg==\",\n  \"crc32c\": \"1759807128\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/04f6c107-9611-4db9-aba8-98841ba5ae6f.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_124.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214218,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"38e2e524-56e0-45e8-9c7a-5bcfef7d0996\"\n  ],\n  \"etag\": \"k1zlBELgPmn1bICELy1gxJsv9to\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.218Z\",\n  \"updated\": \"2024-10-02T01:56:54.218Z\",\n  \"size\": 11304,\n  \"md5Hash\": \"OnTRSZf4UrAvBGsP6dpRkw==\",\n  \"crc32c\": \"1817449649\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/06640a30-4d8e-4812-890e-e0374ad30f58.json",
    "content": "{\n  \"name\": \"mmembed/weather/0604.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213923,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"cecfcbbe-eeaf-4ef9-ab5e-44ba21790b5f\"\n  ],\n  \"etag\": \"bmz1wxRcTnu53n8cpDYvkaH66lM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.923Z\",\n  \"updated\": \"2024-10-02T01:56:53.923Z\",\n  \"size\": 39164,\n  \"md5Hash\": \"cTWL+fraOVtXbXMBki3qhw==\",\n  \"crc32c\": \"733542868\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/06919cbe-0678-4bfd-8cec-4d09e8e2d51b.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_6096.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214338,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"fb36226f-0da6-40d8-a6ef-173a140a0620\"\n  ],\n  \"etag\": \"8hN4lvAQAJ0/e9cB0cZH0CrbVPM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.338Z\",\n  \"updated\": \"2024-10-02T01:56:54.338Z\",\n  \"size\": 15174,\n  \"md5Hash\": \"Qhyo3YCeCNo5WH/YR0ilkg==\",\n  \"crc32c\": \"2745637501\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/0a86afce-8076-4ab8-bd65-c1eaf5ec966f.json",
    "content": "{\n  \"name\": \"mmembed/weather/6098.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214154,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"d8b24713-683b-44ff-bfca-9df78c7bcf05\"\n  ],\n  \"etag\": \"kIZ9xrx7aFSzEiThfihBkZ+5FFE\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.154Z\",\n  \"updated\": \"2024-10-02T01:56:54.154Z\",\n  \"size\": 34624,\n  \"md5Hash\": \"sYV/Q6+C0OlEKSXddwHBAw==\",\n  \"crc32c\": \"268759653\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/0b286de2-4d59-442c-933e-76f0f8e3cd0b.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0008.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214200,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"538b70e3-3370-4967-aa1a-b3de33c7f8c6\"\n  ],\n  \"etag\": \"MXva3ZcgDdY33nOtsq6X9frygJA\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.200Z\",\n  \"updated\": \"2024-10-02T01:56:54.200Z\",\n  \"size\": 5277,\n  \"md5Hash\": \"DTNZaDwevDkcGlGyNcNhhw==\",\n  \"crc32c\": \"1754875255\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/0c96fb25-144b-46eb-bdf1-74c575ddb6e9.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_2211.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214270,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"591fac23-d94b-4da4-a6cb-5fd44faa285b\"\n  ],\n  \"etag\": \"thl3s6qet0XJ8x+5ONXze4WNF88\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.270Z\",\n  \"updated\": \"2024-10-02T01:56:54.270Z\",\n  \"size\": 10048,\n  \"md5Hash\": \"UX9Ow5yj8HNkiyAM4uOgfQ==\",\n  \"crc32c\": \"1287150674\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/0cf49b23-1d83-41c8-85d3-5c31fbf7b123.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_14.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214203,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"8a910879-0a15-4c22-ac27-d125084f50df\"\n  ],\n  \"etag\": \"Xl8PkdCqJlMk7ZX0o2+8e3gM2Sg\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.203Z\",\n  \"updated\": \"2024-10-02T01:56:54.203Z\",\n  \"size\": 14476,\n  \"md5Hash\": \"xhszeLK8WJJmvYJdHF8ejg==\",\n  \"crc32c\": \"1283693818\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/0d5f8ef7-dd37-432a-a0eb-80ccb822abc5.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_1838.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214259,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"cb5d25b0-f932-4135-9bd5-85710727ebb3\"\n  ],\n  \"etag\": \"YPLEIaRetS6Ub0uiuHqx87DEv78\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.259Z\",\n  \"updated\": \"2024-10-02T01:56:54.259Z\",\n  \"size\": 10558,\n  \"md5Hash\": \"Sy9aUKXvSWDfj2bAAZrVww==\",\n  \"crc32c\": \"3896045552\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/0d961bbe-aa5c-4d0e-9e53-dc7a7ec2b96c.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_6102.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214345,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"cf5fc2e7-9996-4d21-98cb-b698b917d9a0\"\n  ],\n  \"etag\": \"hLZuDXUeSdUky+F6t+vBJhT5elU\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.345Z\",\n  \"updated\": \"2024-10-02T01:56:54.345Z\",\n  \"size\": 13974,\n  \"md5Hash\": \"5sDUxjdRXzTO2OCMxVAuvA==\",\n  \"crc32c\": \"2607937928\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/0e7ec18e-93a5-4d40-845b-92cf98040f13.json",
    "content": "{\n  \"name\": \"mmembed/weather/4087.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214084,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"f97a3b60-6f86-4970-8745-f6464984391a\"\n  ],\n  \"etag\": \"Eop0ILMOwPFe5rOUm8xXuPZuY4E\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.084Z\",\n  \"updated\": \"2024-10-02T01:56:54.084Z\",\n  \"size\": 9765,\n  \"md5Hash\": \"y6ig7rdczYOqpELLhBX0cw==\",\n  \"crc32c\": \"1278155514\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/0ea29a34-dbcf-487a-b0e4-dd1c4028c7e2.json",
    "content": "{\n  \"name\": \"mmembed/weather/3608.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214034,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"68d35210-682b-4a2f-a86a-9dcacc59c2ed\"\n  ],\n  \"etag\": \"cezO0mmpl1mcIWGFmMcYHYEqbu0\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.034Z\",\n  \"updated\": \"2024-10-02T01:56:54.034Z\",\n  \"size\": 104825,\n  \"md5Hash\": \"4+FeplRi/qh4ZqskIpUqNQ==\",\n  \"crc32c\": \"2250252144\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/0fb276ab-ad9f-4cd4-95ea-335d18ea72fe.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_1832.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214255,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"bd0a6fc9-eb67-49a6-887d-a7ff1a5a14e5\"\n  ],\n  \"etag\": \"108bJZHvr29ytwB68AJSlpcYO7A\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.255Z\",\n  \"updated\": \"2024-10-02T01:56:54.255Z\",\n  \"size\": 7781,\n  \"md5Hash\": \"6uK6+JT0FLOdoYj6giNR4g==\",\n  \"crc32c\": \"2621317137\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/0ff12c88-f320-4ef7-9356-26fbb9f3590e.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_6090.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214328,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"66e3e843-247a-4af4-8159-0b79497ea45c\"\n  ],\n  \"etag\": \"SZRmNX2yfKybTRX5Zzv2aOmTbh4\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.328Z\",\n  \"updated\": \"2024-10-02T01:56:54.328Z\",\n  \"size\": 19043,\n  \"md5Hash\": \"HcrTuvHvGntt7n7UX3U6Eg==\",\n  \"crc32c\": \"1962143993\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/114b11eb-de0d-441b-85d9-d70db5353156.json",
    "content": "{\n  \"name\": \"mmembed/weather/0596.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213906,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"6b4bd888-826d-40da-8455-fd9d36eda2f8\"\n  ],\n  \"etag\": \"kA0fmU395rCJSy9kUlu2eU09zs0\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.906Z\",\n  \"updated\": \"2024-10-02T01:56:53.906Z\",\n  \"size\": 26170,\n  \"md5Hash\": \"Ll9+GvegxhYsWWWl3jKX+Q==\",\n  \"crc32c\": \"2833660459\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/125f8010-6ab0-463f-8278-5565cfc9d618.json",
    "content": "{\n  \"name\": \"mmembed/weather/4078.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214057,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"f359fd83-11e7-495c-9ea1-78be53dd4cc8\"\n  ],\n  \"etag\": \"ZfLR9mb5RGK1XWPlhlQAz3uyai8\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.057Z\",\n  \"updated\": \"2024-10-02T01:56:54.057Z\",\n  \"size\": 73002,\n  \"md5Hash\": \"4A92XSgT2T+hmhKei9+jfA==\",\n  \"crc32c\": \"1155197054\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/1514936f-769c-40b4-bfe4-4746fbc79fcd.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_4083.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214321,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"dfd792f8-e006-408a-87d1-f6a21579072c\"\n  ],\n  \"etag\": \"2gYDeBUaZcqAXGwGdVz8G08EpJ8\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.321Z\",\n  \"updated\": \"2024-10-02T01:56:54.321Z\",\n  \"size\": 4334,\n  \"md5Hash\": \"E2Z1D7LabIL0k4EpmaIw/A==\",\n  \"crc32c\": \"1297624009\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/16227ec4-2585-40aa-a5fd-e25e0d227b66.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_4075.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214305,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"15381bd4-a7d6-4abc-ad3f-04fd84b24f77\"\n  ],\n  \"etag\": \"iBnYch5qos8DWCNGj3pAlTbAJN8\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.305Z\",\n  \"updated\": \"2024-10-02T01:56:54.305Z\",\n  \"size\": 5133,\n  \"md5Hash\": \"Mw+kimOMiWEANeV3AqVInQ==\",\n  \"crc32c\": \"169736659\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/17f5cac8-e8c5-45c8-b6b3-afaa11b556ff.json",
    "content": "{\n  \"name\": \"mmembed/weather/2209.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213973,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"d9a7b331-6972-4ab5-b8d7-1508a0ab4929\"\n  ],\n  \"etag\": \"IgmKoZNnLR7zXZd+5ArbhQdkWhc\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.973Z\",\n  \"updated\": \"2024-10-02T01:56:53.973Z\",\n  \"size\": 82384,\n  \"md5Hash\": \"C7yZ9OnMw0JZ51nvkP7RMQ==\",\n  \"crc32c\": \"698962223\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/18964cd0-de01-46bd-88c3-ffad0b15b020.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_3613.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214304,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"c5c3077e-f52a-473e-a74c-75f9a0d3b320\"\n  ],\n  \"etag\": \"BfXGJqmGOJsAlqwl6ubReLHk+qc\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.304Z\",\n  \"updated\": \"2024-10-02T01:56:54.304Z\",\n  \"size\": 18350,\n  \"md5Hash\": \"OR4hIX4Vhdzz/iQ/KSOw1A==\",\n  \"crc32c\": \"455266503\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/1aca1226-11c8-42f4-8137-339fe674e55c.json",
    "content": "{\n  \"name\": \"mmembed/weather/6095.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214149,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"2f8553e6-8c5a-4aba-941c-d13db58cfaf1\"\n  ],\n  \"etag\": \"+yrJoeBdo/CM1G17v/Y0SmFE3M0\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.149Z\",\n  \"updated\": \"2024-10-02T01:56:54.149Z\",\n  \"size\": 81105,\n  \"md5Hash\": \"GPGMZc9t79Lggb1KRZSNtQ==\",\n  \"crc32c\": \"948006417\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/1b831e3c-a1e8-4eb4-b3d6-de18b656814a.json",
    "content": "{\n  \"name\": \"mmembed/weather/2217.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213995,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"adc0e2fb-d5b6-4418-90d2-92535c12310f\"\n  ],\n  \"etag\": \"jU0AhhTffx77j3ScNLu/6LuRf40\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.995Z\",\n  \"updated\": \"2024-10-02T01:56:53.995Z\",\n  \"size\": 68848,\n  \"md5Hash\": \"SwFPkUZxZDWTrr3DuK5scA==\",\n  \"crc32c\": \"1077058591\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/1ba5a0d9-2902-4a7a-8f33-862cf0abcb38.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_6103.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214348,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"6a9e9c74-e9a4-4def-94f6-8bcfb52cc6b8\"\n  ],\n  \"etag\": \"YfZBVfKqu4AXmn+QgN2UZR05emg\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.348Z\",\n  \"updated\": \"2024-10-02T01:56:54.348Z\",\n  \"size\": 16840,\n  \"md5Hash\": \"/uhrHofj+BWKGml9qv9wqA==\",\n  \"crc32c\": \"662099688\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/1bd97095-c1d4-4104-ae2a-f6c0e56dde89.json",
    "content": "{\n  \"name\": \"mmembed/weather/6092.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214086,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"e96effde-8178-47ce-8375-89e5145a2721\"\n  ],\n  \"etag\": \"iBywvrK4EqSDI9O17G5m8JPMpNE\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.086Z\",\n  \"updated\": \"2024-10-02T01:56:54.086Z\",\n  \"size\": 17112,\n  \"md5Hash\": \"iQTJaj9t0IVVI0hYIs0wHw==\",\n  \"crc32c\": \"3830179173\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/1ccff42b-89c8-4a66-9d8c-3c7f1c307625.json",
    "content": "{\n  \"name\": \"mmembed/weather/0011.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213858,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"19427ea2-3f15-4e71-bf36-32d722530c64\"\n  ],\n  \"etag\": \"gx5ZMUfvAv04oqA2zzFBD/u8bKU\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.858Z\",\n  \"updated\": \"2024-10-02T01:56:53.858Z\",\n  \"size\": 15393,\n  \"md5Hash\": \"E7W7Vlew1scBd861MX503Q==\",\n  \"crc32c\": \"1275252314\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/1d363b0e-cea6-4e58-aa6f-17c005c32f60.json",
    "content": "{\n  \"name\": \"mmembed/weather/0001.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213557,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"8085ff5c-5413-4741-b0f5-299e34e3e1a2\"\n  ],\n  \"etag\": \"Mqwc0wCZJw1uwdBDeHN7xGRdHco\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.557Z\",\n  \"updated\": \"2024-10-02T01:56:53.557Z\",\n  \"size\": 218742,\n  \"md5Hash\": \"3Rg3Wm7CnzY/B0QnZsQorA==\",\n  \"crc32c\": \"120931931\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/1e2e00f2-eb8c-432d-91a9-1f49c9bb2087.json",
    "content": "{\n  \"name\": \"mmembed/weather/6099.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214158,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"0d1c3d56-eef3-4310-b935-65bea8ba9e4e\"\n  ],\n  \"etag\": \"+m877m7tpsJmyPyMEgYu//gIgp4\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.158Z\",\n  \"updated\": \"2024-10-02T01:56:54.158Z\",\n  \"size\": 65527,\n  \"md5Hash\": \"bFI2tvxK1hO+wszHKTE/nw==\",\n  \"crc32c\": \"1399365792\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/1e72b424-5c06-42c0-99eb-140545880ccb.json",
    "content": "{\n  \"name\": \"mmembed/weather/3607.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214030,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"1d33d2b8-962a-4b08-b1e0-f29daf182d4a\"\n  ],\n  \"etag\": \"4OFoLgemkP+zygQa2bRy2pLUyow\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.030Z\",\n  \"updated\": \"2024-10-02T01:56:54.030Z\",\n  \"size\": 246261,\n  \"md5Hash\": \"bsRZT6YLVGdOSVM8YJzlTQ==\",\n  \"crc32c\": \"2299355626\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/1e859368-8d5b-4ecc-b30f-116ec3377f39.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_2212.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214272,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"32c1a17a-7572-466f-9745-bb7e667a4979\"\n  ],\n  \"etag\": \"vMjrh2kmVK8QAU9wIraQ93a7KGs\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.272Z\",\n  \"updated\": \"2024-10-02T01:56:54.272Z\",\n  \"size\": 9950,\n  \"md5Hash\": \"QvSFmZiKMrEfuWEzYKvl7A==\",\n  \"crc32c\": \"3884699602\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/1f67aea5-0f3e-4b12-9554-aae3a84d5354.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0596.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214228,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"5225a644-9c32-4cba-b14f-e1ae79f11147\"\n  ],\n  \"etag\": \"KM+gVubYdAYUldlE0VJoSuTosa0\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.228Z\",\n  \"updated\": \"2024-10-02T01:56:54.228Z\",\n  \"size\": 6210,\n  \"md5Hash\": \"LVEw6Xwzf++yAHiLgd/yNg==\",\n  \"crc32c\": \"1432835939\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/1fb32b8f-72d0-43ac-8955-d3804be94bc2.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0001.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214179,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"6c8078c4-600e-43d4-a8af-52e1ff9e18a2\"\n  ],\n  \"etag\": \"tkSlwBaTv24h5q21flXIu+Xf4/Y\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.179Z\",\n  \"updated\": \"2024-10-02T01:56:54.179Z\",\n  \"size\": 14648,\n  \"md5Hash\": \"N+dUOYoP/oppURUSs157MA==\",\n  \"crc32c\": \"3009774670\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/2166725e-18e6-4982-aeb7-2f4925461bf1.json",
    "content": "{\n  \"name\": \"mmembed/weather/0000.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213450,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"4e6ac25b-6323-49fa-bc71-6fb369af0a5e\"\n  ],\n  \"etag\": \"3otu6CamzNyNi7Zkp2ImHdWTYJ0\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.450Z\",\n  \"updated\": \"2024-10-02T01:56:53.450Z\",\n  \"size\": 539583,\n  \"md5Hash\": \"t2BU1iGJwgI8oe00QcK5GQ==\",\n  \"crc32c\": \"2394330913\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/21af1f86-eb45-421d-90ff-962fc5509c16.json",
    "content": "{\n  \"name\": \"mmembed/weather/14.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213869,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"23528ff9-cf7d-4ce3-9c68-f5558f3b1c86\"\n  ],\n  \"etag\": \"yXv128JIIxm7cj7OpCpp+8Jaz3c\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.869Z\",\n  \"updated\": \"2024-10-02T01:56:53.869Z\",\n  \"size\": 12281,\n  \"md5Hash\": \"bj3/BPS2ORR2w3UrQMxiAg==\",\n  \"crc32c\": \"2392310858\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/2883e822-976c-4d81-9601-2df44f96bcd7.json",
    "content": "{\n  \"name\": \"mmembed/weather/4083.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214080,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"f299c49b-f15e-494c-8642-a323b2e03d46\"\n  ],\n  \"etag\": \"xOdR8bqTejiFN+HDOfNN4dKQ8FU\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.080Z\",\n  \"updated\": \"2024-10-02T01:56:54.080Z\",\n  \"size\": 9973,\n  \"md5Hash\": \"TEGsZAg7x3VN8tFwOuUyUg==\",\n  \"crc32c\": \"1292903988\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/28a96371-1742-4e03-ae5c-ba309216af49.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_1841.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214269,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"6d86ec72-cf93-4c07-ad63-8e02f6be5a71\"\n  ],\n  \"etag\": \"T2I9ooRH9XiOwoXbGwNyHFM2BGY\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.269Z\",\n  \"updated\": \"2024-10-02T01:56:54.269Z\",\n  \"size\": 10400,\n  \"md5Hash\": \"iOKioGTPqxA7qhe7x7Wufw==\",\n  \"crc32c\": \"3469634376\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/2ae61d7e-7bc1-4695-bd44-3637adbef998.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_6093.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214340,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"69e0d26c-0a24-449d-851d-db8d32b38b49\"\n  ],\n  \"etag\": \"7rK/yz/gky35aqf8PmYNRGxq0jg\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.340Z\",\n  \"updated\": \"2024-10-02T01:56:54.340Z\",\n  \"size\": 17448,\n  \"md5Hash\": \"WjAc7XrTOPSUy3Ltq6oFpw==\",\n  \"crc32c\": \"1331630286\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/2c938160-fe51-4222-a7d1-8220ad4ee300.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_4082.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214326,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"51f886c7-c036-4347-8b0b-b2f0d1a9f55d\"\n  ],\n  \"etag\": \"QFlGAx9m7HJvZTOOD9NZupSYApU\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.326Z\",\n  \"updated\": \"2024-10-02T01:56:54.326Z\",\n  \"size\": 5818,\n  \"md5Hash\": \"mtj/V1ZK1Y1Mb/dFBOgiEQ==\",\n  \"crc32c\": \"1408660470\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/2de6149c-40aa-471f-94a1-2dd2824724d8.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0007.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214190,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"1419c924-6261-47aa-aabe-a470be29f8ce\"\n  ],\n  \"etag\": \"XEWb/Loy0exX9f04vZmhk9+EBv0\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.190Z\",\n  \"updated\": \"2024-10-02T01:56:54.190Z\",\n  \"size\": 19346,\n  \"md5Hash\": \"m6yrk4ZgrlJ67p0O+DVlRw==\",\n  \"crc32c\": \"3479293480\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/2eeccd3b-575d-4055-95d9-c5fbf26b3a8d.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_2216.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214277,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"4648d52a-f921-4234-a607-d88b62487598\"\n  ],\n  \"etag\": \"auX1cmVBD/17tF27I7zZTyx4VjE\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.277Z\",\n  \"updated\": \"2024-10-02T01:56:54.277Z\",\n  \"size\": 6507,\n  \"md5Hash\": \"i2QVTpLoamZ9Cj1xvV+Bag==\",\n  \"crc32c\": \"2210589049\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/2efc3c4b-2601-4d8f-ba2b-8d84712c436e.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_11.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214196,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"b96a290a-566a-4de6-a487-9e0f509e461d\"\n  ],\n  \"etag\": \"iOUZXj1k5QKjhtQng9VyVHcbzi0\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.196Z\",\n  \"updated\": \"2024-10-02T01:56:54.196Z\",\n  \"size\": 14061,\n  \"md5Hash\": \"S4oEhbzFkrgxd6ixg91JLw==\",\n  \"crc32c\": \"1517782914\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/2f4a0b48-dc3f-4fd4-be4d-726c56619302.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_3602.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214294,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"84cc53aa-a000-4e02-8e24-32fdea71365c\"\n  ],\n  \"etag\": \"jUoSabPE8i+hVjFNEdB0JkTIwEc\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.294Z\",\n  \"updated\": \"2024-10-02T01:56:54.294Z\",\n  \"size\": 12040,\n  \"md5Hash\": \"QWuRQ9F5TXUurWjPLGe7VA==\",\n  \"crc32c\": \"2494328559\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/30621326-86e9-4f15-b5ea-6eaad59947df.json",
    "content": "{\n  \"name\": \"mmembed/weather/4082.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214066,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"e11b0d61-19fc-4dd4-b4f0-b92407bb965d\"\n  ],\n  \"etag\": \"Ze8BABPSms/vd2PkLE1Z9SAFF4A\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.066Z\",\n  \"updated\": \"2024-10-02T01:56:54.066Z\",\n  \"size\": 16960,\n  \"md5Hash\": \"dEiw7DU5S8vPYOvi/VfNTA==\",\n  \"crc32c\": \"228427894\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/30d12518-b643-48cf-8608-7aa9771fed14.json",
    "content": "{\n  \"name\": \"mmembed/weather/3609.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214037,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"ccc8f319-a810-4fdc-bef7-c2bfdab00930\"\n  ],\n  \"etag\": \"pnwR5LZQC8lZj7RQGQvhPJ9tzDE\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.037Z\",\n  \"updated\": \"2024-10-02T01:56:54.037Z\",\n  \"size\": 143499,\n  \"md5Hash\": \"y+RzIsmE2+VsbJhCWcMIJQ==\",\n  \"crc32c\": \"651195968\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/31500630-b5a2-4092-86b1-5f2565716d86.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_6094.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214335,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"b19bb670-10e8-497f-9238-ded4dd899073\"\n  ],\n  \"etag\": \"Mwr/++U/0H668FSaesqfByC4kj4\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.335Z\",\n  \"updated\": \"2024-10-02T01:56:54.335Z\",\n  \"size\": 17637,\n  \"md5Hash\": \"DwzqcITq7ZDSJogk5R6GaA==\",\n  \"crc32c\": \"3284771992\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/31719f53-507c-4a3f-8b29-0be61178f3e7.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_2210.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214275,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"34415b4d-9e1f-4be4-9df1-7aef31140398\"\n  ],\n  \"etag\": \"CkbtX+LCJyZhXT/BNzV0r5gCiH8\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.276Z\",\n  \"updated\": \"2024-10-02T01:56:54.276Z\",\n  \"size\": 15346,\n  \"md5Hash\": \"MICuMh82GVetlEC8r2PBrw==\",\n  \"crc32c\": \"3962969\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/354b878a-20c1-4b79-8a41-2b96e63eedaf.json",
    "content": "{\n  \"name\": \"mmembed/weather/1832.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213931,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"15dc2f43-1e53-4160-b493-be3fa1fbf299\"\n  ],\n  \"etag\": \"Qsu6aHLyVQE8DguCAX61r8VOt2Y\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.931Z\",\n  \"updated\": \"2024-10-02T01:56:53.931Z\",\n  \"size\": 21470,\n  \"md5Hash\": \"6KMmupRZkC9RMGMWxxkfhQ==\",\n  \"crc32c\": \"3889189560\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/362a4319-eac2-4157-82f8-4cc5624ab0ee.json",
    "content": "{\n  \"name\": \"mmembed/weather/1830.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213927,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"8633bfd8-fdeb-4532-842b-263478513c9a\"\n  ],\n  \"etag\": \"bHSJJivNdoBD2K7VMEjQoV6MF80\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.927Z\",\n  \"updated\": \"2024-10-02T01:56:53.927Z\",\n  \"size\": 19711,\n  \"md5Hash\": \"yVz8XZqd+ZX9bzdzBJupaA==\",\n  \"crc32c\": \"4225590086\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/36a45009-4146-4860-ab0f-2d41d6e1b8d2.json",
    "content": "{\n  \"name\": \"mmembed/weather/4076.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214054,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"ae201ab0-7327-4f2f-aa06-3cc039dab0c5\"\n  ],\n  \"etag\": \"P+il+WDRk4p+Cqn9GBOjnIggbGg\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.054Z\",\n  \"updated\": \"2024-10-02T01:56:54.054Z\",\n  \"size\": 12157,\n  \"md5Hash\": \"nsPT4IB6yyfOSSXNEe+KGw==\",\n  \"crc32c\": \"1705056797\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/36b5a479-0181-4f1b-98f4-86a64d4e1191.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_6095.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214337,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"e29649ec-2d82-4cbd-998e-bc4c7ca41ecc\"\n  ],\n  \"etag\": \"8nbYjyHjoydaAQu8pAfkFccefCU\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.337Z\",\n  \"updated\": \"2024-10-02T01:56:54.337Z\",\n  \"size\": 25531,\n  \"md5Hash\": \"aYuWXI+gnZxzfkWSovd1EA==\",\n  \"crc32c\": \"1859669618\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/37be70be-0250-4281-9532-401a7bf29f41.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_3608.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214296,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"35b88cd4-bd58-4881-bf94-f20b77fc7038\"\n  ],\n  \"etag\": \"LtOfvpJcw/EVFyw7gt3nnJ3UygE\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.296Z\",\n  \"updated\": \"2024-10-02T01:56:54.296Z\",\n  \"size\": 19393,\n  \"md5Hash\": \"1uRmOJsaB325LMlg4Wy+Tg==\",\n  \"crc32c\": \"2188821813\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/3954ab46-6a25-4bb6-ab32-f0d55c546cb2.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0004.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214184,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"10e18a5e-2431-4eaf-b489-77cf13d11712\"\n  ],\n  \"etag\": \"PSz+Ibnmn+cA50WnfCoBsbpHBCo\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.185Z\",\n  \"updated\": \"2024-10-02T01:56:54.185Z\",\n  \"size\": 27633,\n  \"md5Hash\": \"BJspDnOFSkMM+JY8HfR9+g==\",\n  \"crc32c\": \"1932905604\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/3b33aa14-5d04-43bd-9ef6-e30b8861d7db.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_3605.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214290,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"afad32ad-efeb-47ff-9e15-72a687ce2665\"\n  ],\n  \"etag\": \"mn3ybaESziO5wllyrZVV6RczGfk\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.290Z\",\n  \"updated\": \"2024-10-02T01:56:54.290Z\",\n  \"size\": 20474,\n  \"md5Hash\": \"8jBq+kn7/xfvWTrL/Tr30A==\",\n  \"crc32c\": \"553899297\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/3b688492-90d3-4d82-b6fc-499bc09e56eb.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_2219.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214280,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"d9836b61-01bd-4df1-919b-2d69c724fbff\"\n  ],\n  \"etag\": \"FSlhznQZPghjj5uTHUELB5Imnlo\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.280Z\",\n  \"updated\": \"2024-10-02T01:56:54.280Z\",\n  \"size\": 13864,\n  \"md5Hash\": \"H33u22ATlioSkFG8thlnPQ==\",\n  \"crc32c\": \"4002763423\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/3dccd76d-64a7-47c2-a9b5-eea9b2efa12f.json",
    "content": "{\n  \"name\": \"mmembed/weather/0602.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213929,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"dfed8198-4140-4450-b2f5-1d2805a40d60\"\n  ],\n  \"etag\": \"P+mSd8wV1G439q46mGCt438GbTM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.929Z\",\n  \"updated\": \"2024-10-02T01:56:53.929Z\",\n  \"size\": 109768,\n  \"md5Hash\": \"k7ydXLiycPsoidFdEuHu8A==\",\n  \"crc32c\": \"162010201\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/3e64b976-d7b3-4356-92d7-0aeb15f45fd5.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_13.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214201,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"66e7266d-10bf-486c-9126-d8dac1b15809\"\n  ],\n  \"etag\": \"yngrIBH9iuyvpKNiGEJAdDLC7Cg\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.201Z\",\n  \"updated\": \"2024-10-02T01:56:54.201Z\",\n  \"size\": 27096,\n  \"md5Hash\": \"46lmqGK4rtyIRbWh6LfP7g==\",\n  \"crc32c\": \"2478750340\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/3e966f04-2910-4104-8f1e-412bf33917c0.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0006.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214188,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"e24b256d-029f-4a98-b546-f8f463c21b40\"\n  ],\n  \"etag\": \"rgl3RZiXiNF0csHqahBwQ89zauU\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.188Z\",\n  \"updated\": \"2024-10-02T01:56:54.188Z\",\n  \"size\": 14753,\n  \"md5Hash\": \"aN+1iWnlB98OdT14uX64cw==\",\n  \"crc32c\": \"492567308\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/412215ae-10af-413a-9f75-861667cf12fe.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0598.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214241,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"6cfaf55b-dcd1-4b6f-a730-9729abf0f64b\"\n  ],\n  \"etag\": \"ou3cFpP7u2pjUcJR8wQjOoC5LD4\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.241Z\",\n  \"updated\": \"2024-10-02T01:56:54.241Z\",\n  \"size\": 9244,\n  \"md5Hash\": \"lqF9mhBbyP+p6dlFI0j4NQ==\",\n  \"crc32c\": \"3454634838\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/41e09631-0791-4051-96ce-4e18d05f2be9.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0005.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214187,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"75718b25-82b7-4e90-b111-01ee7f7501e8\"\n  ],\n  \"etag\": \"l4hQ9871l41vTqT9e6wY9XsNGaU\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.187Z\",\n  \"updated\": \"2024-10-02T01:56:54.187Z\",\n  \"size\": 13574,\n  \"md5Hash\": \"i70e6kiJHFl+XSWmk1wjZw==\",\n  \"crc32c\": \"1731491201\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/4223ad2c-c8fc-4f31-8454-284312b22905.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0010.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214195,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"27c1191c-c2ac-4335-9d73-42b1ed62c84d\"\n  ],\n  \"etag\": \"jTrHC/sAc9PZLECjdXVb+7byJyk\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.195Z\",\n  \"updated\": \"2024-10-02T01:56:54.195Z\",\n  \"size\": 5356,\n  \"md5Hash\": \"rPwsVftTIFRvP1zq6iK2Ng==\",\n  \"crc32c\": \"3949780381\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/42694520-432b-4928-adce-006db533f6f8.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_6092.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214332,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"f3430f0c-b1d2-4c52-bcc6-a57cbade8045\"\n  ],\n  \"etag\": \"NCgaSVMevOt1C8m+6Fr0gsjLj4o\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.332Z\",\n  \"updated\": \"2024-10-02T01:56:54.332Z\",\n  \"size\": 6491,\n  \"md5Hash\": \"LZiiA9C0yKxdIRIw4xyt3g==\",\n  \"crc32c\": \"2951203737\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/4418a01d-24ec-4e4b-b29a-e0689b395969.json",
    "content": "{\n  \"name\": \"mmembed/weather/124.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213896,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"727ba136-973a-44ff-96a7-324a32039900\"\n  ],\n  \"etag\": \"QPh1gzuG0mlEDneHO0WsyiE3Os4\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.896Z\",\n  \"updated\": \"2024-10-02T01:56:53.896Z\",\n  \"size\": 23894,\n  \"md5Hash\": \"77tgnbLz73hZDIqZrscJ/A==\",\n  \"crc32c\": \"887769651\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/45131a81-db3a-4f9b-9330-5e2eb2b3eb44.json",
    "content": "{\n  \"name\": \"mmembed/weather/1840.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213960,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"f96a5ad8-d2ea-4f08-b51b-3d549fad432c\"\n  ],\n  \"etag\": \"9aNgHf1isHpLEQ/dX6OYJtZeBac\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.960Z\",\n  \"updated\": \"2024-10-02T01:56:53.960Z\",\n  \"size\": 32933,\n  \"md5Hash\": \"fBaaR9VX06ivQCsBVI7lVw==\",\n  \"crc32c\": \"1744366507\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/454a16c7-6ec4-4c29-838f-6d235d729c0d.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_3604.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214289,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"1fb990cf-1058-43da-9a6c-5431270d0631\"\n  ],\n  \"etag\": \"YV2mgi2fgaUZY3wCplu2PyIvgtE\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.289Z\",\n  \"updated\": \"2024-10-02T01:56:54.289Z\",\n  \"size\": 9632,\n  \"md5Hash\": \"tWJj5paDt7Y9mANH1RzXjQ==\",\n  \"crc32c\": \"3605923073\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/45b0127e-7f92-437f-97e4-f96b50ca60f6.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_1839.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214260,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"5c03cd07-50cd-4141-a3fb-56d99223449d\"\n  ],\n  \"etag\": \"xpVrhqw9D43bFkcMzE/G6zq2hwA\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.260Z\",\n  \"updated\": \"2024-10-02T01:56:54.260Z\",\n  \"size\": 4612,\n  \"md5Hash\": \"Oa0H97mjZ42z5tf8UMeeRw==\",\n  \"crc32c\": \"3687014695\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/460d8563-7063-413d-aa9c-d50fb8c9a0a3.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_3607.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214303,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"bfceba7b-408a-49d2-85ff-e669a6f21292\"\n  ],\n  \"etag\": \"9/ihJD9hQ3QdVufOVqNw3w4mygY\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.303Z\",\n  \"updated\": \"2024-10-02T01:56:54.303Z\",\n  \"size\": 13181,\n  \"md5Hash\": \"AtNGp96+AAAw/PprhuKCjw==\",\n  \"crc32c\": \"169596319\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/464ea502-b1a7-47a1-af48-7b4296b13896.json",
    "content": "{\n  \"name\": \"mmembed/weather/0592.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213907,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"7d909733-ac14-4419-9068-e6da95783596\"\n  ],\n  \"etag\": \"jF9SYVzxUBOu63GW3cv9Yje4dmI\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.907Z\",\n  \"updated\": \"2024-10-02T01:56:53.907Z\",\n  \"size\": 53095,\n  \"md5Hash\": \"uo5N9JedSjjUTUSgkHjERA==\",\n  \"crc32c\": \"3657379677\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/477f1478-d154-44c1-9b3e-f7bc08b81b1b.json",
    "content": "{\n  \"name\": \"mmembed/weather/106.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213886,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"90a32e61-40fb-4fca-9ec8-52f4ff67910a\"\n  ],\n  \"etag\": \"KU9A0y3v84BH7jWUB/E2+WBsqbU\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.886Z\",\n  \"updated\": \"2024-10-02T01:56:53.886Z\",\n  \"size\": 21849,\n  \"md5Hash\": \"MjPpZUODEzK0TP6Kh01Npw==\",\n  \"crc32c\": \"1516988867\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/47a173ba-dd3f-4e02-8409-e46bf5c831ca.json",
    "content": "{\n  \"name\": \"mmembed/weather/0593.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213900,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"affcfda3-e19b-49d9-b612-190638a797ce\"\n  ],\n  \"etag\": \"9bXcK+zQ3JGuLkG/s4gPCgd8730\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.900Z\",\n  \"updated\": \"2024-10-02T01:56:53.900Z\",\n  \"size\": 115829,\n  \"md5Hash\": \"WSV/E6ETEDN9oBU2bQaaDw==\",\n  \"crc32c\": \"486872548\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/47eb894d-5a60-4c64-b22a-f496f63a869e.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_6091.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214330,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"6b314ac1-6a05-48af-bc1d-90b7ae087ebe\"\n  ],\n  \"etag\": \"eCbgGLT9fJ3DUO/h7imZ2AHczUA\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.330Z\",\n  \"updated\": \"2024-10-02T01:56:54.330Z\",\n  \"size\": 33125,\n  \"md5Hash\": \"0imAdgbpsQj5mf2mSE3IlA==\",\n  \"crc32c\": \"2996066578\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/487cbb33-22c6-41f6-8e6e-4125927e2f20.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0599.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214236,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"bc930d72-2e0f-47e8-aecd-3a675237fc85\"\n  ],\n  \"etag\": \"vj6gSRIu6WeVkExwwyAQkFn1uGc\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.236Z\",\n  \"updated\": \"2024-10-02T01:56:54.236Z\",\n  \"size\": 2991,\n  \"md5Hash\": \"zTZmMLaiduXHKI01iY/Flg==\",\n  \"crc32c\": \"1443784698\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/495964a6-f764-487d-aec8-a1c1c02d8be3.json",
    "content": "{\n  \"name\": \"mmembed/weather/2215.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213991,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"72fa668e-e67e-4ea9-9764-c16bc4773f15\"\n  ],\n  \"etag\": \"AtZ4gamvXGLf+YRil6SNAj1pfuU\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.991Z\",\n  \"updated\": \"2024-10-02T01:56:53.991Z\",\n  \"size\": 50405,\n  \"md5Hash\": \"JfggXOu+UjX0A1CeVOuq5g==\",\n  \"crc32c\": \"4280889935\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/4ad91f5f-7c9e-40e4-9c4f-c03a22bd8b2f.json",
    "content": "{\n  \"name\": \"mmembed/weather/1838.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213957,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"9f8afbdd-8a32-4dfe-b4a6-b6070664bb6e\"\n  ],\n  \"etag\": \"COWpWHEwcd6MwTwlvxGc4MPkIfI\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.957Z\",\n  \"updated\": \"2024-10-02T01:56:53.957Z\",\n  \"size\": 27264,\n  \"md5Hash\": \"i1akiaYkbk4T08zRt79olw==\",\n  \"crc32c\": \"2625837919\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/5012b076-ef07-41c8-9da7-033e2af2f579.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_102.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214204,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"35444da3-8425-41ce-8886-a756cebdb00b\"\n  ],\n  \"etag\": \"hz1LPcgP16jv74x/9TEEsXyla9o\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.204Z\",\n  \"updated\": \"2024-10-02T01:56:54.204Z\",\n  \"size\": 18821,\n  \"md5Hash\": \"QuP/MBSHHwd9lAnOBYhwzg==\",\n  \"crc32c\": \"3789881989\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/50b673c2-e1b9-4b84-bbcc-1c47cca0cace.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_4076.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214307,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"54735d91-caa8-452d-86a8-9fb579ca50c8\"\n  ],\n  \"etag\": \"/AgTV/fbMen2FufU7J1LivfxsLM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.307Z\",\n  \"updated\": \"2024-10-02T01:56:54.307Z\",\n  \"size\": 7830,\n  \"md5Hash\": \"eKCZRMiLJl3+wCa6Ad+YMQ==\",\n  \"crc32c\": \"259118115\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/530a9038-98b8-4925-b26b-f5c7776d882f.json",
    "content": "{\n  \"name\": \"mmembed/weather/3606.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214024,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"a25f918e-095e-409e-9582-95bb85c15cde\"\n  ],\n  \"etag\": \"LaNXGBnFQeeqhbqMLTtEWm8Amtw\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.024Z\",\n  \"updated\": \"2024-10-02T01:56:54.024Z\",\n  \"size\": 603596,\n  \"md5Hash\": \"nUykxJA/bboVK7I0WyOPGQ==\",\n  \"crc32c\": \"3099203018\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/55e55a5c-a59a-4c7f-846a-73784ee2f25d.json",
    "content": "{\n  \"name\": \"mmembed/weather/0003.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213621,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"cecf4476-5182-4f6c-95ec-713426b8f2ad\"\n  ],\n  \"etag\": \"bngEx7bxIOkZBqosHMubh2Bb2W8\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.621Z\",\n  \"updated\": \"2024-10-02T01:56:53.621Z\",\n  \"size\": 13733,\n  \"md5Hash\": \"xnrgPwP2jPZJ9ZoTuyMicA==\",\n  \"crc32c\": \"1739771885\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/563a8d9a-0285-4496-aaba-59138c349dad.json",
    "content": "{\n  \"name\": \"mmembed/weather/6093.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214137,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"c4c2d5f9-0a4e-42ff-9058-0cee4c01e695\"\n  ],\n  \"etag\": \"G/GFdMINYijbjonEHXRxH0YFVYs\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.137Z\",\n  \"updated\": \"2024-10-02T01:56:54.137Z\",\n  \"size\": 53877,\n  \"md5Hash\": \"/aIkhYm4NNIlaQkb5fKomw==\",\n  \"crc32c\": \"549122946\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/58852f43-76ea-4ad1-9c6a-a33c2c53ff56.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0601.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214238,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"fe6531cf-0cec-4ae0-93fe-5d94475829a5\"\n  ],\n  \"etag\": \"sI5kosKgk35AGzECbhiIX+vPHGg\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.239Z\",\n  \"updated\": \"2024-10-02T01:56:54.239Z\",\n  \"size\": 7430,\n  \"md5Hash\": \"4MqvPK+1q4def/E0KPTLzg==\",\n  \"crc32c\": \"3466385387\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/5a3ce50b-2658-4f68-b12d-0eec92a7223a.json",
    "content": "{\n  \"name\": \"mmembed/weather/0013.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213868,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"60ec79c0-9e88-4281-bcfe-2bb5b0e7f044\"\n  ],\n  \"etag\": \"QUvjciRqphu3PDPLkQKuWoKLm0k\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.868Z\",\n  \"updated\": \"2024-10-02T01:56:53.868Z\",\n  \"size\": 16205,\n  \"md5Hash\": \"1CfteG+uGzrpmbkhkb6igw==\",\n  \"crc32c\": \"574220549\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/5a3ecd67-d20b-405d-83f7-86ff772f6d6f.json",
    "content": "{\n  \"name\": \"mmembed/weather/3600.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214020,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"b32b4d21-7e78-423a-b9d1-37cca1400f72\"\n  ],\n  \"etag\": \"zgCG856MJkxlhgYOu02M4YMLM7c\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.020Z\",\n  \"updated\": \"2024-10-02T01:56:54.020Z\",\n  \"size\": 28028,\n  \"md5Hash\": \"WGmoF47MOum3Zo9vrtl/zQ==\",\n  \"crc32c\": \"1934178353\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/5b388511-e3b8-4d3f-b0bf-d085f75c7769.json",
    "content": "{\n  \"name\": \"mmembed/weather/2211.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213984,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"fab174f1-6742-496f-998e-af5e71067a2f\"\n  ],\n  \"etag\": \"fhqY84X6UV1dhUqv5MfliUM12eE\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.984Z\",\n  \"updated\": \"2024-10-02T01:56:53.984Z\",\n  \"size\": 152175,\n  \"md5Hash\": \"pUCkLaR99u2yCieH5py8Kw==\",\n  \"crc32c\": \"2764797711\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/5b62fd41-e2a2-4ba6-b305-a854619a947b.json",
    "content": "{\n  \"name\": \"mmembed/weather/0605.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213925,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"911f6664-eb6f-4476-8da5-68819c391377\"\n  ],\n  \"etag\": \"TJqzPSAUC+x6vpxI1N7pVPjMbWQ\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.925Z\",\n  \"updated\": \"2024-10-02T01:56:53.925Z\",\n  \"size\": 17757,\n  \"md5Hash\": \"HREGccJqC4+lpvGR56RK7w==\",\n  \"crc32c\": \"503857714\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/5ef2346d-d39e-49ce-b26a-9e5069dba421.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_1830.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214245,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"4949bd26-82c5-4328-9fc9-5c1c477e4a16\"\n  ],\n  \"etag\": \"kCrj3756USzzYaASOZ9tNKvNK9M\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.245Z\",\n  \"updated\": \"2024-10-02T01:56:54.245Z\",\n  \"size\": 5036,\n  \"md5Hash\": \"h/sR7Qoo78md/48zjEPIxQ==\",\n  \"crc32c\": \"1710719719\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/5fd6d66a-8bf1-4f81-b0eb-9895552a8ec4.json",
    "content": "{\n  \"name\": \"mmembed/weather/6103.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214166,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"a2845089-4277-46f2-8291-e124787ef0df\"\n  ],\n  \"etag\": \"W+b3frXMQ1FDhyQLZlQ8ixdFD5M\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.166Z\",\n  \"updated\": \"2024-10-02T01:56:54.166Z\",\n  \"size\": 68373,\n  \"md5Hash\": \"8QLdvu/5AP3zd5dImHZnQg==\",\n  \"crc32c\": \"2865485624\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/61556f93-4dd2-43e5-bc4d-78cbe3809d42.json",
    "content": "{\n  \"name\": \"mmembed/weather/1843.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213969,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"f5423ad6-87f2-4d5a-b959-06ee45f01396\"\n  ],\n  \"etag\": \"uT8QFPTu8s27gzWZimzKk7/M0ys\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.969Z\",\n  \"updated\": \"2024-10-02T01:56:53.969Z\",\n  \"size\": 26236,\n  \"md5Hash\": \"eMH7jvZfzrPwfJ9IZz+X0Q==\",\n  \"crc32c\": \"4270796985\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/620c1be4-e353-46d9-8dbd-c3079d5efd6e.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_2218.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214279,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"58d03cd2-022d-41b1-af74-25b198b75efc\"\n  ],\n  \"etag\": \"wISTnZ7oQX3KlA5+eQxvduQivls\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.279Z\",\n  \"updated\": \"2024-10-02T01:56:54.279Z\",\n  \"size\": 14001,\n  \"md5Hash\": \"EWjvR1y5f0eUP5GQgVXUog==\",\n  \"crc32c\": \"1369497715\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/62b85256-0f54-47cb-ae83-d45cd0a66030.json",
    "content": "{\n  \"name\": \"mmembed/weather/1833.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213934,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"2200ead9-e8fb-4775-a6a3-27c67987d98f\"\n  ],\n  \"etag\": \"8KvdjysS1w/6Jx/CXiAhOrsDfjE\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.934Z\",\n  \"updated\": \"2024-10-02T01:56:53.934Z\",\n  \"size\": 126668,\n  \"md5Hash\": \"v9uxoePXfz4QnXChJbrUmA==\",\n  \"crc32c\": \"4133910751\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/64609c87-07e8-48a4-b8c2-a26210e1dd0b.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_3606.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214292,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"ff760ced-f87e-418e-aa83-3669f7c845a7\"\n  ],\n  \"etag\": \"d8WfrO0CvzDVV4i+JWM5RO2Ho0c\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.292Z\",\n  \"updated\": \"2024-10-02T01:56:54.292Z\",\n  \"size\": 22335,\n  \"md5Hash\": \"VC1N5in+R2BcGYVPf/Kq1Q==\",\n  \"crc32c\": \"1788696116\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/64f13cdd-ee8c-4238-b66b-14c1baa578e3.json",
    "content": "{\n  \"name\": \"mmembed/weather/4086.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214077,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"cb0014b8-6ccc-4d64-82b4-f476cc5a2e48\"\n  ],\n  \"etag\": \"SmReYlruPXIo6Dito/maMYsHf4k\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.077Z\",\n  \"updated\": \"2024-10-02T01:56:54.077Z\",\n  \"size\": 39256,\n  \"md5Hash\": \"YftzDtUDeaO5EFSmvIojwQ==\",\n  \"crc32c\": \"3662402265\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/65c4dee4-4429-4eae-9250-2e969f3c190a.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_4080.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214313,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"ac5cb996-588f-4035-a466-4ffe00c42639\"\n  ],\n  \"etag\": \"HzUU1MrNtOOlZ078SldpFf1EfsY\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.313Z\",\n  \"updated\": \"2024-10-02T01:56:54.313Z\",\n  \"size\": 5787,\n  \"md5Hash\": \"5+gro/fUDo1nRuO3CK+hkg==\",\n  \"crc32c\": \"3803752996\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/66569702-2074-4047-a794-4f11e3b285f2.json",
    "content": "{\n  \"name\": \"mmembed/weather/4075.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214047,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"dd222bb2-836f-453d-acd3-7fa7b2f6a9b9\"\n  ],\n  \"etag\": \"nO8pOfVz8+kIv0lo9dnv2j+/dTM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.047Z\",\n  \"updated\": \"2024-10-02T01:56:54.047Z\",\n  \"size\": 47697,\n  \"md5Hash\": \"IvqpVthzTYHdDdmfSUnmYg==\",\n  \"crc32c\": \"2393731617\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/67aaca3d-2acc-4987-8ed9-3f2fbf075f7c.json",
    "content": "{\n  \"name\": \"mmembed/weather/2214.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213989,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"3beaf521-4b5a-4816-bbc8-3bc0c12a99ed\"\n  ],\n  \"etag\": \"prmucYR8TJuDuF+GvTj6op9jMR4\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.989Z\",\n  \"updated\": \"2024-10-02T01:56:53.989Z\",\n  \"size\": 55305,\n  \"md5Hash\": \"APKEA6TLVXzgcbH7zY3jKA==\",\n  \"crc32c\": \"1323378059\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/67b6aeb1-b7d0-4402-8a60-b2f9c0c8b447.json",
    "content": "{\n  \"name\": \"mmembed/weather/2216.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213993,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"48e4100d-8870-45a1-a633-5675c0efa081\"\n  ],\n  \"etag\": \"W/0gQsvuevg6427l1/ZceAaTKWo\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.993Z\",\n  \"updated\": \"2024-10-02T01:56:53.993Z\",\n  \"size\": 110773,\n  \"md5Hash\": \"nRTETV67es90Vo5xN2Y2NQ==\",\n  \"crc32c\": \"560262473\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/686d4f0b-5c28-48f4-b656-b354a13981a7.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0600.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214237,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"68380d0a-fff9-4091-9064-7b616de385bf\"\n  ],\n  \"etag\": \"2JcvxU9inHjh8QVo850iqruXdkg\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.237Z\",\n  \"updated\": \"2024-10-02T01:56:54.237Z\",\n  \"size\": 7463,\n  \"md5Hash\": \"oYm//K8/M991iaB6LGf8ow==\",\n  \"crc32c\": \"652887366\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/69657785-7299-408d-8453-8cb32f848150.json",
    "content": "{\n  \"name\": \"mmembed/weather/2220.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213998,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"9723402e-e7fe-4b66-a194-abe8da711d8a\"\n  ],\n  \"etag\": \"b82ImF8DBQznCWrkutLCaXCjJuE\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.998Z\",\n  \"updated\": \"2024-10-02T01:56:53.998Z\",\n  \"size\": 240631,\n  \"md5Hash\": \"480XiYifKZmg83tZurcLyQ==\",\n  \"crc32c\": \"1959190398\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/6b6f7c8b-a5b9-4436-80a0-f128c1eb3bcf.json",
    "content": "{\n  \"name\": \"mmembed/weather/2218.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214004,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"4b6ee53f-d130-4a73-a421-d1359e461f9d\"\n  ],\n  \"etag\": \"O9zYErRG7jjrp/SEqaM9lvdpnZc\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.004Z\",\n  \"updated\": \"2024-10-02T01:56:54.004Z\",\n  \"size\": 105169,\n  \"md5Hash\": \"Y4ivqX2d4Emu/acyy2o9ug==\",\n  \"crc32c\": \"1468776016\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/6d8f5f9c-fef6-4179-ba05-79e361503c7a.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_1842.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214264,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"d5a42bc8-a9e7-4ead-b187-729a19222992\"\n  ],\n  \"etag\": \"5rl9oLEuswNcG8Lnk8ppUdvzCkk\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.264Z\",\n  \"updated\": \"2024-10-02T01:56:54.264Z\",\n  \"size\": 3739,\n  \"md5Hash\": \"P5BLZCL8FIaSbMlwm9xVvg==\",\n  \"crc32c\": \"2919985240\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/70772163-4c3b-493a-ad04-87b67ee35d71.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_119.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214223,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"212998eb-efa9-468e-85cf-bb928dfc8cb6\"\n  ],\n  \"etag\": \"osvQLJW5RYqc9O1+M4kktPNGOdU\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.223Z\",\n  \"updated\": \"2024-10-02T01:56:54.223Z\",\n  \"size\": 184568,\n  \"md5Hash\": \"rgkaIA10HXFLSyWM7VDBoA==\",\n  \"crc32c\": \"3219441820\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/70c43a85-254c-49b1-bf14-d13687913f2d.json",
    "content": "{\n  \"name\": \"mmembed/weather/0010.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213851,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"32ab255e-b06d-4753-8c1d-10bb472e2894\"\n  ],\n  \"etag\": \"+0wujO4BADxdGadQDJf/oiN4sXw\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.851Z\",\n  \"updated\": \"2024-10-02T01:56:53.851Z\",\n  \"size\": 11953,\n  \"md5Hash\": \"c8tR5NDTdqZqEzgz8PBvVA==\",\n  \"crc32c\": \"871164411\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/70db2153-5bbf-42f6-91bc-f3cb4249dc01.json",
    "content": "{\n  \"name\": \"mmembed/weather/109.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213877,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"5c9d4026-f8f3-4fa7-9317-61a4c872dc33\"\n  ],\n  \"etag\": \"TmdBvuaECpRq8XJVAmhp8TZj/I4\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.877Z\",\n  \"updated\": \"2024-10-02T01:56:53.877Z\",\n  \"size\": 48526,\n  \"md5Hash\": \"sLc3SM09LHnm78qJTmwjeA==\",\n  \"crc32c\": \"3051722894\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/70ef85a5-fb7a-41ff-ac02-0d00927d0274.json",
    "content": "{\n  \"name\": \"mmembed/weather/119.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213893,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"c574255b-e4dc-4a82-ba2b-2bd3982641e7\"\n  ],\n  \"etag\": \"n7eIUY/8C2YdIiJGosQ0P9hDy8M\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.894Z\",\n  \"updated\": \"2024-10-02T01:56:53.894Z\",\n  \"size\": 201953,\n  \"md5Hash\": \"cSLL1qbqsPZq1pXKp2TrGg==\",\n  \"crc32c\": \"3016202184\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/70fc8a82-bfeb-4bb7-bb95-2a654168ead1.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_6101.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214344,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"5e6448d9-9bac-497e-bf6b-8dfc1d4a860c\"\n  ],\n  \"etag\": \"Yi46ueXmnar/+xX180By/71gQs0\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.344Z\",\n  \"updated\": \"2024-10-02T01:56:54.344Z\",\n  \"size\": 21495,\n  \"md5Hash\": \"/2aXK45YqlzyWLGsC0QM1A==\",\n  \"crc32c\": \"105193236\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/71894824-0cd9-4acf-9178-abba58090e42.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0000.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214177,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"660e7b50-a1c9-4143-bcc6-4baa330cbf41\"\n  ],\n  \"etag\": \"oLrsvK0/XrJhucp15NIcQBLgRok\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.177Z\",\n  \"updated\": \"2024-10-02T01:56:54.177Z\",\n  \"size\": 21893,\n  \"md5Hash\": \"pPKb1T0Pbn9tznH4TrieHA==\",\n  \"crc32c\": \"2709331183\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/721bdd57-7850-42e4-9fb9-747525b187c8.json",
    "content": "{\n  \"name\": \"mmembed/weather/2210.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213978,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"833069fd-9274-48ae-a473-01dcb55c52d2\"\n  ],\n  \"etag\": \"NxOLgVfi2bGXsjWj6ijsyeyjMDI\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.979Z\",\n  \"updated\": \"2024-10-02T01:56:53.979Z\",\n  \"size\": 137409,\n  \"md5Hash\": \"MY1mvHenkUQDerOoOyEUag==\",\n  \"crc32c\": \"3024698090\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/7706c66e-3fd9-45eb-9df5-8308b84dd194.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0594.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214234,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"f031d5e0-75b3-485c-9a3b-1f5a29a72aea\"\n  ],\n  \"etag\": \"xnMo0nveehbnlKqQOxgRrpuui0s\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.234Z\",\n  \"updated\": \"2024-10-02T01:56:54.234Z\",\n  \"size\": 6568,\n  \"md5Hash\": \"6bYB+1yEYyn9Suxz9hXAZw==\",\n  \"crc32c\": \"2042269277\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/7846c694-8e7a-47dd-b8b8-a2448285dcc0.json",
    "content": "{\n  \"name\": \"mmembed/weather/2221.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214002,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"c451dc62-9aa4-41af-a8fe-d491e9fdef94\"\n  ],\n  \"etag\": \"4HTyUZHaphEnrmTrO2LOoUMGypI\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.002Z\",\n  \"updated\": \"2024-10-02T01:56:54.002Z\",\n  \"size\": 130087,\n  \"md5Hash\": \"nwRhNI02O2iYOBeFc3a/Xw==\",\n  \"crc32c\": \"876363489\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/78a3c767-265f-419d-bb3f-c3eba6e22017.json",
    "content": "{\n  \"name\": \"mmembed/weather/6097.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214152,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"3ac00b79-78be-476b-982d-4ac07a2c116a\"\n  ],\n  \"etag\": \"qHOhIUmZxyiGUGxuUiKEBCP1nRM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.152Z\",\n  \"updated\": \"2024-10-02T01:56:54.152Z\",\n  \"size\": 10514,\n  \"md5Hash\": \"W5tvMEYVy7Ub1NRioyV5rg==\",\n  \"crc32c\": \"2863558512\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/78dd9bfd-a0ca-4ac9-9473-45d0d051fb1e.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_4085.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214323,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"7e6394a8-d4a2-46bd-a30a-2b94a3908f5a\"\n  ],\n  \"etag\": \"6rqOmp0TTT9YbTORm/n3lcpgP58\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.323Z\",\n  \"updated\": \"2024-10-02T01:56:54.323Z\",\n  \"size\": 5733,\n  \"md5Hash\": \"fOWQsKphj47TRFJRfT7WGw==\",\n  \"crc32c\": \"1594951559\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/799fcdd0-1054-4c53-a218-48cb8f3ed044.json",
    "content": "{\n  \"name\": \"mmembed/weather/3610.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214050,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"5a3a7482-f47c-4b5d-a0ff-d29297bdbbc4\"\n  ],\n  \"etag\": \"OF4HrO4/YpO4AT1EwjB7sIYwwNk\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.050Z\",\n  \"updated\": \"2024-10-02T01:56:54.050Z\",\n  \"size\": 330766,\n  \"md5Hash\": \"HohL6/hC2Ez78T5XWzgqEQ==\",\n  \"crc32c\": \"3322523345\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/7a5a7379-d29b-412b-9a50-565e171ab712.json",
    "content": "{\n  \"name\": \"mmembed/weather/3612.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214060,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"b1954fab-edb2-4ee0-9bc3-adb3d62d58c0\"\n  ],\n  \"etag\": \"Nm4gapwheutdZv89Xd7uxYtoYtA\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.060Z\",\n  \"updated\": \"2024-10-02T01:56:54.060Z\",\n  \"size\": 178642,\n  \"md5Hash\": \"KmX44pUzFa3EhNHDKIqR2A==\",\n  \"crc32c\": \"3247754625\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/7c12f79f-d474-446f-b5fe-f669e60f8227.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0009.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214193,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"5ed76562-4a28-4a25-a454-33aeec4b27c9\"\n  ],\n  \"etag\": \"c6a8/GHF0OBZghgQJA+FPE8PMtY\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.193Z\",\n  \"updated\": \"2024-10-02T01:56:54.193Z\",\n  \"size\": 7599,\n  \"md5Hash\": \"sSv5IQBzTLfSFfv+fMbmoQ==\",\n  \"crc32c\": \"714336334\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/7c4a2540-75b9-4e37-b4b1-a6d7f62830cb.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_110.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214216,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"14381b2f-76b1-4858-8842-4ac8e3a72cc0\"\n  ],\n  \"etag\": \"wEBBzEAU5NNvVNaNskfGZftch84\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.216Z\",\n  \"updated\": \"2024-10-02T01:56:54.216Z\",\n  \"size\": 14074,\n  \"md5Hash\": \"4Q6ugWCDx4YcCNItDXwdVg==\",\n  \"crc32c\": \"3833393849\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/7c689600-cf26-4953-8be5-ae58faa5cad0.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_1834.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214251,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"b836a652-7f4e-4545-bf82-3656ff9cbc6b\"\n  ],\n  \"etag\": \"psNiCiLdvSLyXwhqNqehbVT4lbM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.251Z\",\n  \"updated\": \"2024-10-02T01:56:54.251Z\",\n  \"size\": 6774,\n  \"md5Hash\": \"/wYDZinpYwHynnPfoCRzhw==\",\n  \"crc32c\": \"2304315075\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/7d321a59-d6c9-433e-bfcb-2ee10dcd2745.json",
    "content": "{\n  \"name\": \"mmembed/weather/6102.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214182,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"71cf147d-11c1-4140-b568-91d6bf1924c8\"\n  ],\n  \"etag\": \"Gnq9yHrKiPQDBEnUSJpJWSqyP8A\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.183Z\",\n  \"updated\": \"2024-10-02T01:56:54.183Z\",\n  \"size\": 37928,\n  \"md5Hash\": \"j0RLbyd4qOiwNxeLBKSBfQ==\",\n  \"crc32c\": \"1082194485\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/7ff005e0-3f17-423f-9a04-1d762447eb07.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_3603.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214295,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"765b3423-3bf3-4eb0-9438-51248125b826\"\n  ],\n  \"etag\": \"FPNmjT38rhVqlG1znVGsBDXz2r0\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.295Z\",\n  \"updated\": \"2024-10-02T01:56:54.295Z\",\n  \"size\": 18808,\n  \"md5Hash\": \"xeU3eYbTPsZt6FJv+klnrg==\",\n  \"crc32c\": \"4276021197\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/8278f4eb-708a-4054-b408-9b4a2a8d6b7a.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0012.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214206,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"86363b5c-2edc-431c-aa57-fb789e19e15d\"\n  ],\n  \"etag\": \"Re2SnK2f4i7a2/8UEm3Xh9n49AE\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.206Z\",\n  \"updated\": \"2024-10-02T01:56:54.206Z\",\n  \"size\": 62035,\n  \"md5Hash\": \"JHwk8v4LncD3y+JVoV180g==\",\n  \"crc32c\": \"3453592618\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/828f697d-fd40-470e-8326-8a40dccf9096.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_4081.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214314,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"ed661fc5-b1e6-4597-8284-c64af03761b2\"\n  ],\n  \"etag\": \"qzBi9T2GqaMx0BBPe6ykGWUgC50\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.315Z\",\n  \"updated\": \"2024-10-02T01:56:54.315Z\",\n  \"size\": 6778,\n  \"md5Hash\": \"FGrKvi2M4dHY/ARO8XdqCA==\",\n  \"crc32c\": \"1041255470\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/832cacff-0ed7-4685-a383-a0d94e6a366e.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_1835.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214252,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"c584bd98-19f5-438c-a9ee-75588cc152d4\"\n  ],\n  \"etag\": \"Gs4GJ7eJOJbHT2qKZ4yeWTudJxU\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.252Z\",\n  \"updated\": \"2024-10-02T01:56:54.252Z\",\n  \"size\": 5994,\n  \"md5Hash\": \"Giyn9TwDMkj6kMUMR4gd9w==\",\n  \"crc32c\": \"2908595589\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/84307d32-0903-4a1c-8599-9d3938995da6.json",
    "content": "{\n  \"name\": \"mmembed/weather/4080.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214063,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"3a1c3103-356e-4827-8cc6-b35d17322317\"\n  ],\n  \"etag\": \"Kv7Zw01geZzRdVPSpOGqDtYcohs\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.063Z\",\n  \"updated\": \"2024-10-02T01:56:54.063Z\",\n  \"size\": 8026,\n  \"md5Hash\": \"JKH1aDs6nAB7Uhgzyidt5A==\",\n  \"crc32c\": \"2659669049\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/84491769-76dd-4667-825d-95156d99ba3f.json",
    "content": "{\n  \"name\": \"mmembed/weather/1834.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213937,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"d4374770-9e98-48f4-a2a2-976adcf0b616\"\n  ],\n  \"etag\": \"T6RhNbSkZaFszl1Z1t99eM7km20\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.937Z\",\n  \"updated\": \"2024-10-02T01:56:53.937Z\",\n  \"size\": 21036,\n  \"md5Hash\": \"k1IrjYDX/6607vDOAoCiYg==\",\n  \"crc32c\": \"2629582504\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/848c006e-a50c-4d24-9e96-f9b12ab5f66b.json",
    "content": "{\n  \"name\": \"mmembed/weather/1841.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213976,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"34df9bb7-e2cc-4c82-92c5-c19951c81a07\"\n  ],\n  \"etag\": \"G2hY5uaL2Jm/vbyqgHvtmYZaFDc\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.976Z\",\n  \"updated\": \"2024-10-02T01:56:53.976Z\",\n  \"size\": 14853,\n  \"md5Hash\": \"SwZNMDw/oX7nNJ268sP3BQ==\",\n  \"crc32c\": \"2387074229\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/8628a1cb-e588-483c-b00f-ba449d2c27b1.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_4088.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214327,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"14e18b82-4118-4fff-80cd-1f9c353fd485\"\n  ],\n  \"etag\": \"6490ilJZi9nTg1qvfBYHE64mbgM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.327Z\",\n  \"updated\": \"2024-10-02T01:56:54.327Z\",\n  \"size\": 10184,\n  \"md5Hash\": \"u9Lj0Ehs/UZsG9DJybxE5Q==\",\n  \"crc32c\": \"2106708215\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/87039e64-1d6f-4f28-92e5-322ae686133d.json",
    "content": "{\n  \"name\": \"mmembed/weather/105.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213883,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"802cad08-fc65-45bc-8b06-6be101576302\"\n  ],\n  \"etag\": \"BtPa6dlRKjdPv+bTEwbDNtyP1DA\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.883Z\",\n  \"updated\": \"2024-10-02T01:56:53.883Z\",\n  \"size\": 123336,\n  \"md5Hash\": \"kos1aAwpwf6znM3t7OUcCA==\",\n  \"crc32c\": \"384242595\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/884b9432-28fb-465b-a826-35daec2593d7.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_2221.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214284,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"9c88c876-ca87-46fa-bf84-377d5453ad65\"\n  ],\n  \"etag\": \"azo0G2Qsb2f+RMu+gTDHL5lcIOc\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.284Z\",\n  \"updated\": \"2024-10-02T01:56:54.284Z\",\n  \"size\": 18035,\n  \"md5Hash\": \"oju9GCokmZDblCIjBxTPnQ==\",\n  \"crc32c\": \"2237944386\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/8bc987b7-d22f-4331-8de5-7149d9b73361.json",
    "content": "{\n  \"name\": \"mmembed/weather/117.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213891,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"280e750c-b154-40fe-aaf3-0ab0eb1588e0\"\n  ],\n  \"etag\": \"6r2ntJ2f8bmdCsMAAxYlqfwO+qo\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.891Z\",\n  \"updated\": \"2024-10-02T01:56:53.891Z\",\n  \"size\": 114157,\n  \"md5Hash\": \"KM8Lta/C3oRXpV7I0RkgIw==\",\n  \"crc32c\": \"3742704072\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/8cd45f77-4225-412e-9c61-bb90285356c5.json",
    "content": "{\n  \"name\": \"mmembed/weather/1835.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213939,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"13f1be4e-a3ee-420e-a123-48885f29579e\"\n  ],\n  \"etag\": \"32u/odGUtAV/NYBGd5V9CpMX8Mo\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.939Z\",\n  \"updated\": \"2024-10-02T01:56:53.939Z\",\n  \"size\": 13504,\n  \"md5Hash\": \"XvklOeaT7ev+Mh33VlYN1w==\",\n  \"crc32c\": \"2503840333\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/8e0d391e-c779-4a34-a8de-f6abccc701ab.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0603.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214247,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"fd65958f-fa37-4e7e-ada8-47d933e22a96\"\n  ],\n  \"etag\": \"D1jH8RfEQJpJgLcPg5r0PML3mQ8\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.247Z\",\n  \"updated\": \"2024-10-02T01:56:54.247Z\",\n  \"size\": 15876,\n  \"md5Hash\": \"5tQg5E/w40Mkdua09k63/Q==\",\n  \"crc32c\": \"2729687274\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/8f014805-5f51-439d-9447-b686abf889fa.json",
    "content": "{\n  \"name\": \"mmembed/weather/3602.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214011,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"e60d8db1-26b5-4d79-88cb-374a5f6f4bfb\"\n  ],\n  \"etag\": \"e8+B8CZxaNL78KRIpMfj0HGgFEs\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.011Z\",\n  \"updated\": \"2024-10-02T01:56:54.011Z\",\n  \"size\": 74273,\n  \"md5Hash\": \"Ys/5itQE89+ClXMSlmSe8g==\",\n  \"crc32c\": \"3942032353\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/91006bcb-22a2-480d-83ba-5393a41c6558.json",
    "content": "{\n  \"name\": \"mmembed/weather/3605.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214107,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"188c4bb8-a521-40dc-92de-bf988bc0183e\"\n  ],\n  \"etag\": \"TDpSCbLAqEYfe0JRgaT15dHb+Hs\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.107Z\",\n  \"updated\": \"2024-10-02T01:56:54.107Z\",\n  \"size\": 3589046,\n  \"md5Hash\": \"Wp4c0dtf3fxU3n3risBxyA==\",\n  \"crc32c\": \"959535698\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/91bbc49d-9748-4638-a75c-573f23ff7c5e.json",
    "content": "{\n  \"name\": \"mmembed/weather/4088.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214082,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"c151feb4-ec2e-4ea9-93d1-05d49db873b1\"\n  ],\n  \"etag\": \"vYmeFyqOUeG2lQb4zR5aPSet/X8\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.082Z\",\n  \"updated\": \"2024-10-02T01:56:54.082Z\",\n  \"size\": 17114,\n  \"md5Hash\": \"TgIp7BRkROD0tRukk27uIg==\",\n  \"crc32c\": \"2219821404\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/92e62795-a847-46bc-a983-8f2f975f4ac3.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0593.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214232,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"507d7c43-bd28-40fd-b14f-b0e964e23306\"\n  ],\n  \"etag\": \"kqapa8uPK5NWJhrMxCfZs4wBsJ4\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.232Z\",\n  \"updated\": \"2024-10-02T01:56:54.232Z\",\n  \"size\": 169233,\n  \"md5Hash\": \"tz/Qdxc3hM2YqJVzRDx6NQ==\",\n  \"crc32c\": \"4281738524\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/92e944f9-bcfa-4e3c-95ad-2d6619cc377b.json",
    "content": "{\n  \"name\": \"mmembed/weather/0598.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213909,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"84fbced3-8931-400d-aed7-b31a5520f25e\"\n  ],\n  \"etag\": \"EBgsNdTVze7PdMu8lNazC4u3GSI\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.909Z\",\n  \"updated\": \"2024-10-02T01:56:53.909Z\",\n  \"size\": 28392,\n  \"md5Hash\": \"hAI0WD3dLJ+1mX68fEy7FA==\",\n  \"crc32c\": \"1132548163\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/934f914b-21f8-45e6-812c-ac29926508c3.json",
    "content": "{\n  \"name\": \"mmembed/weather/0599.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213910,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"afc97942-bc1b-461b-86e4-bc7005df7bb2\"\n  ],\n  \"etag\": \"+TODb7WJrIplvXv806U1TuiWPuQ\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.910Z\",\n  \"updated\": \"2024-10-02T01:56:53.910Z\",\n  \"size\": 20624,\n  \"md5Hash\": \"yK+tJqWmz53zwLyS2iiuAQ==\",\n  \"crc32c\": \"2349321410\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/96d4fbff-3d58-4cef-b6f0-393751f39a80.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_4084.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214322,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"5476c9d8-6fdd-4903-bde8-25fd8ae68345\"\n  ],\n  \"etag\": \"r4IOmFr3nJC7lqUyARhmRp1tvq8\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.322Z\",\n  \"updated\": \"2024-10-02T01:56:54.322Z\",\n  \"size\": 4026,\n  \"md5Hash\": \"kgowcv9VxuIi1k5mxECMWw==\",\n  \"crc32c\": \"2107554066\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/98ca7091-441b-471c-a3cc-7a7e94826534.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_4079.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214311,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"9e8635ef-e482-40f2-a49a-f7b60b812ab7\"\n  ],\n  \"etag\": \"SUQOdUTyUD32Qi2A/rCXU3lIheQ\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.311Z\",\n  \"updated\": \"2024-10-02T01:56:54.311Z\",\n  \"size\": 5019,\n  \"md5Hash\": \"08pndY7X9eAzq0/BixDxZA==\",\n  \"crc32c\": \"3429608544\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/991c80af-1b3f-454c-8716-c45ed7023269.json",
    "content": "{\n  \"name\": \"mmembed/weather/1831.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213951,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"dee95dda-b2b5-4271-b0fe-b7c50b4fa2e0\"\n  ],\n  \"etag\": \"mYaZ7sHh/u1YoFWxX+J2HvJrKyU\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.951Z\",\n  \"updated\": \"2024-10-02T01:56:53.951Z\",\n  \"size\": 68794,\n  \"md5Hash\": \"R3mwgiyk/n3dq7/Ax/gtFQ==\",\n  \"crc32c\": \"207836512\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/9ab4a412-9175-410b-8281-65999c677106.json",
    "content": "{\n  \"name\": \"mmembed/weather/4084.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214072,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"3a2ef093-d5aa-4594-ab55-0cdd4e2949dd\"\n  ],\n  \"etag\": \"QstnidhZPti25z7Vgs5ublIiX9g\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.072Z\",\n  \"updated\": \"2024-10-02T01:56:54.072Z\",\n  \"size\": 22280,\n  \"md5Hash\": \"NOZ/X7vNFN1MixibhAbD7g==\",\n  \"crc32c\": \"2636184080\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/9d4fbb56-2a44-4866-9489-03d9312fdd55.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_1843.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214265,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"89e520fa-25a3-42b6-883d-ddfd4476f797\"\n  ],\n  \"etag\": \"rqlonyolY69cl8n6D9YTGTDDvnE\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.265Z\",\n  \"updated\": \"2024-10-02T01:56:54.265Z\",\n  \"size\": 5332,\n  \"md5Hash\": \"SlR/COm5Tv3kmFq5SzVw4Q==\",\n  \"crc32c\": \"1983870227\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/9edac21b-4d29-4a5a-a5cd-5aac086224df.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0013.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214202,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"d052916d-1766-44d5-a8fb-9ac98b753dc8\"\n  ],\n  \"etag\": \"VW/AjkoxOnIhefpVvIwgQxKPLJw\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.202Z\",\n  \"updated\": \"2024-10-02T01:56:54.202Z\",\n  \"size\": 9211,\n  \"md5Hash\": \"gKh6DGNGkrWAj+S2repcMg==\",\n  \"crc32c\": \"1459402094\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/a0eb55dd-239b-4908-8acd-a89c2bf639c2.json",
    "content": "{\n  \"name\": \"mmembed/weather/1842.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213965,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"f73caa35-c228-4767-af66-3abd8763112a\"\n  ],\n  \"etag\": \"tfLUtlpBy8yfeaNl1+5VEAJj9d0\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.965Z\",\n  \"updated\": \"2024-10-02T01:56:53.965Z\",\n  \"size\": 17788,\n  \"md5Hash\": \"ylcdiQTEH3ItQHqW+V9K+Q==\",\n  \"crc32c\": \"1776676022\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/a1539350-b2f4-4c95-a3d4-6b85d493f2b1.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_105.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214207,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"ba66c2a2-55ec-4530-9017-ebb1c213b355\"\n  ],\n  \"etag\": \"l5n13n7Fsdjpl5QEZc9d0RS1Lh4\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.207Z\",\n  \"updated\": \"2024-10-02T01:56:54.207Z\",\n  \"size\": 12851,\n  \"md5Hash\": \"Eog0P5vFRcIDPOpj5q4Xug==\",\n  \"crc32c\": \"1280532754\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/a1daa863-1f24-4f91-964b-55e133f8c36d.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_4086.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214325,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"7e0ae61b-0083-48e4-893d-a842c2884a4d\"\n  ],\n  \"etag\": \"DmDymLNCv28xeq8y72qKi67PCdU\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.325Z\",\n  \"updated\": \"2024-10-02T01:56:54.325Z\",\n  \"size\": 8430,\n  \"md5Hash\": \"ui4eiCowyTBR1+9R66fgcg==\",\n  \"crc32c\": \"2657306875\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/a1f4669b-2187-4feb-92dd-1a321a0d3023.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_1840.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214261,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"0835be87-34c7-4117-8a4f-3b9753ba1739\"\n  ],\n  \"etag\": \"wOI0mp6w5bBUI+2s4pk5D1LXZd8\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.261Z\",\n  \"updated\": \"2024-10-02T01:56:54.261Z\",\n  \"size\": 11127,\n  \"md5Hash\": \"R12968FX1+Dvik3jjKaMWQ==\",\n  \"crc32c\": \"1157085364\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/a3f79d63-f265-4971-997b-4e64a25f806f.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_2214.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214274,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"69b0c522-21a5-417c-b849-b1db3a8193f4\"\n  ],\n  \"etag\": \"QXDOgg4KJGOtFyJWiBPGXqbN8WM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.274Z\",\n  \"updated\": \"2024-10-02T01:56:54.274Z\",\n  \"size\": 7686,\n  \"md5Hash\": \"t1veamN5SMkdCXUyUWQUcQ==\",\n  \"crc32c\": \"2350001268\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/a3fcb879-7be9-4a4f-8099-d237cf97418f.json",
    "content": "{\n  \"name\": \"mmembed/weather/113.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213889,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"d1d69b3f-f3b9-49d4-b5a5-4259bce9581b\"\n  ],\n  \"etag\": \"6vYGY1I5XrX84ijSPDM1LutcCJ8\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.889Z\",\n  \"updated\": \"2024-10-02T01:56:53.889Z\",\n  \"size\": 110758,\n  \"md5Hash\": \"YBRzeGj959PyYDQLRXJeDg==\",\n  \"crc32c\": \"2662519531\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/a764ed81-66b1-4d49-9013-b75c1c6ba383.json",
    "content": "{\n  \"name\": \"mmembed/weather/0601.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213913,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"6740f1fd-266a-4651-8923-2b74012388a6\"\n  ],\n  \"etag\": \"tDRYwDCU+vfoQzOgc2IhwyS0trc\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.913Z\",\n  \"updated\": \"2024-10-02T01:56:53.913Z\",\n  \"size\": 32870,\n  \"md5Hash\": \"xGgm8PsD99kXF6LfPzrwcw==\",\n  \"crc32c\": \"1870009792\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/a7855fa7-b5be-4ac3-a4e5-d4893ad338f6.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0597.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214230,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"105d1924-8fcd-43f7-b3ff-6d4c1afa0b56\"\n  ],\n  \"etag\": \"Zy8r+Jos2bbk/M1rC+WWc3B9II4\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.230Z\",\n  \"updated\": \"2024-10-02T01:56:54.230Z\",\n  \"size\": 4610,\n  \"md5Hash\": \"crQdxFnZfMCnZnll8FMTTw==\",\n  \"crc32c\": \"3051176703\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/a8c3e42a-8ebd-490f-b1da-d9046b7aa4ed.json",
    "content": "{\n  \"name\": \"mmembed/weather/1837.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213954,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"197977d1-3b95-41c6-bbbf-ca51347d3473\"\n  ],\n  \"etag\": \"TNSHZ3XGA47SOkDRcYLP3Cnqyvg\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.954Z\",\n  \"updated\": \"2024-10-02T01:56:53.954Z\",\n  \"size\": 75538,\n  \"md5Hash\": \"LrcyHyfusYabd5IVqfUZdA==\",\n  \"crc32c\": \"3080933789\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/a99818ab-a299-4e87-9662-265cbe8e1160.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0604.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214242,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"6395c963-a755-41ed-9911-c2d0f4081ab6\"\n  ],\n  \"etag\": \"nM37ONJGzwmLiBb2tOjHbQcEQ0o\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.242Z\",\n  \"updated\": \"2024-10-02T01:56:54.242Z\",\n  \"size\": 13286,\n  \"md5Hash\": \"Z+SdAnBONB13yQ3zZdXLPg==\",\n  \"crc32c\": \"3643910164\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/ab183f61-767e-4020-bae5-928ca15abd23.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_113.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214212,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"1c561cb3-95e1-4043-8ffb-04c3bee65276\"\n  ],\n  \"etag\": \"HTjKHoARPiOwa8WOGeSWcuyY12E\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.212Z\",\n  \"updated\": \"2024-10-02T01:56:54.212Z\",\n  \"size\": 15136,\n  \"md5Hash\": \"SQcJHBZLIETGLfVOz04sXg==\",\n  \"crc32c\": \"3094000443\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/ad7b8aa9-f8f7-43d5-96cf-b222b5e57318.json",
    "content": "{\n  \"name\": \"mmembed/weather/0009.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213850,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"53086afa-d482-44f0-adf2-df022260308b\"\n  ],\n  \"etag\": \"Cga7V9k72995hHhgNOjEqTRcrdk\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.850Z\",\n  \"updated\": \"2024-10-02T01:56:53.850Z\",\n  \"size\": 15634,\n  \"md5Hash\": \"b3gcpLQ5ndU3oWchwuBLDQ==\",\n  \"crc32c\": \"1426962121\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/ad8f7da2-4988-40ec-9042-85c3f4ca6404.json",
    "content": "{\n  \"name\": \"mmembed/weather/6096.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214097,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"a9a2216b-171f-4336-b445-cbda515d1718\"\n  ],\n  \"etag\": \"7zdTg2Q5oXsH3Q67AEr8S8i7IW8\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.097Z\",\n  \"updated\": \"2024-10-02T01:56:54.097Z\",\n  \"size\": 196102,\n  \"md5Hash\": \"MCMAITc9qxl82MSw0JlNag==\",\n  \"crc32c\": \"1062677583\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/ae4d2687-9c0e-49b2-9401-271fb42e62e1.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0011.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214198,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"c8109ffd-8346-40b0-a846-155e3dcfea2e\"\n  ],\n  \"etag\": \"4vh9Togq09jOrOVHAH6vs8Z8RkY\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.198Z\",\n  \"updated\": \"2024-10-02T01:56:54.198Z\",\n  \"size\": 16476,\n  \"md5Hash\": \"D/G+pPa8vC1rGXCg4ffgXQ==\",\n  \"crc32c\": \"1656521018\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/b17a8866-876a-4d32-8b84-5e19b8bcc5f4.json",
    "content": "{\n  \"name\": \"mmembed/weather/0006.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213713,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"2d9cfaee-a109-44f0-855b-c144e4044994\"\n  ],\n  \"etag\": \"soMwdnctKWUxjoD9tg1VE1f8xBQ\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.713Z\",\n  \"updated\": \"2024-10-02T01:56:53.713Z\",\n  \"size\": 21282,\n  \"md5Hash\": \"ZSzsJvzpxfCzD/p+349qXQ==\",\n  \"crc32c\": \"3731318405\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/b1d4c0bb-0181-45cc-a1d7-7f2832b49b6d.json",
    "content": "{\n  \"name\": \"mmembed/weather/110.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213898,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"27a3a924-ab57-4d5a-a8ff-d81236112d54\"\n  ],\n  \"etag\": \"iyakWIDxzqaGFGFDyONMMO5UbZg\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.898Z\",\n  \"updated\": \"2024-10-02T01:56:53.898Z\",\n  \"size\": 67148,\n  \"md5Hash\": \"4xY5TmFLK/WXOMqCKHrPEA==\",\n  \"crc32c\": \"3311609552\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/b2e7394a-32cb-45ca-abea-73d0b2df628f.json",
    "content": "{\n  \"name\": \"mmembed/weather/\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834196911,\n  \"contentType\": \"application/octet-stream\",\n  \"storageClass\": \"STANDARD\",\n  \"downloadTokens\": [],\n  \"etag\": \"xwacWyejeTGv1yfcfm/3ZooA52w\",\n  \"timeCreated\": \"2024-10-02T01:56:36.911Z\",\n  \"updated\": \"2024-10-02T01:56:36.911Z\",\n  \"size\": 130,\n  \"md5Hash\": \"iUpceipDkyQhwwUflucD5w==\",\n  \"crc32c\": \"3108464614\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/b499dd2d-cd18-488c-9927-c2f1f786a762.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_3612.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214310,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"01d5ba10-6fd8-49f6-80f2-aa6bac742846\"\n  ],\n  \"etag\": \"/YRhMgr8eheFPsCX8KFB78HQEns\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.310Z\",\n  \"updated\": \"2024-10-02T01:56:54.310Z\",\n  \"size\": 16336,\n  \"md5Hash\": \"T1bArYzY7nyXKSASWQlklw==\",\n  \"crc32c\": \"2031343978\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/b7ab64f1-76c2-459d-b6a9-cc7d52e689d1.json",
    "content": "{\n  \"name\": \"mmembed/weather/2213.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213988,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"48e3e417-c77e-484d-bb1a-03fbca625189\"\n  ],\n  \"etag\": \"YtQkuIARtzneUJW4owE8jKq341g\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.988Z\",\n  \"updated\": \"2024-10-02T01:56:53.988Z\",\n  \"size\": 52274,\n  \"md5Hash\": \"kV2VJYuNi0oPgb1K+s84UQ==\",\n  \"crc32c\": \"362995356\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/b8008ef2-2fb5-40ed-8fef-211863635165.json",
    "content": "{\n  \"name\": \"mmembed/weather/6100.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214161,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"8067901f-da0c-4e20-a48d-b0e0e93f6b4f\"\n  ],\n  \"etag\": \"wjxBlgxnVQTr0P6jVVNJxthz5K4\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.161Z\",\n  \"updated\": \"2024-10-02T01:56:54.161Z\",\n  \"size\": 25506,\n  \"md5Hash\": \"+3ieQjRm5md3wKKcfvboXQ==\",\n  \"crc32c\": \"2522224318\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/b8f8d67b-85c4-4734-a413-7e1c5b1a7a9d.json",
    "content": "{\n  \"name\": \"mmembed/weather/0012.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213863,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"d19b4b07-8dab-4a1b-9126-d09a994d2da2\"\n  ],\n  \"etag\": \"y2gPpo2ynDGv5Ha6HdQgA29R4pw\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.864Z\",\n  \"updated\": \"2024-10-02T01:56:53.864Z\",\n  \"size\": 111884,\n  \"md5Hash\": \"Szaom6zXY97ZEM9/ejG9Mw==\",\n  \"crc32c\": \"752167445\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/ba5f66c2-65e8-4ecc-829d-28dd2ddf625a.json",
    "content": "{\n  \"name\": \"mmembed/weather/4085.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214075,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"c05a91c4-cd75-4cb3-b47c-7b377c06b920\"\n  ],\n  \"etag\": \"a6C3suf5Q75NrMuYtGNb+wB9VMc\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.075Z\",\n  \"updated\": \"2024-10-02T01:56:54.075Z\",\n  \"size\": 188547,\n  \"md5Hash\": \"9vRCHvv72hY1V/RIvw9dRw==\",\n  \"crc32c\": \"20400396\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/ba7eb319-928d-45ba-bce3-4edd8f3955bd.json",
    "content": "{\n  \"name\": \"mmembed/weather/4079.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214069,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"fc14f523-6886-473f-b0fa-81371d92de9a\"\n  ],\n  \"etag\": \"CcsYgnYuFa8yKN02ghR67g6jsTo\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.069Z\",\n  \"updated\": \"2024-10-02T01:56:54.069Z\",\n  \"size\": 15518,\n  \"md5Hash\": \"FDgZbuFgx0O6jd+kYxYj4w==\",\n  \"crc32c\": \"1541586509\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/ba9ba128-b58e-47c1-97ee-4b2f52524cdf.json",
    "content": "{\n  \"name\": \"mmembed/weather/102.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213872,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"e0f3e359-a479-41e7-a836-3dd2f9176637\"\n  ],\n  \"etag\": \"z9IXgFfTD10kG4bUlZKpI9nznNU\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.872Z\",\n  \"updated\": \"2024-10-02T01:56:53.872Z\",\n  \"size\": 98275,\n  \"md5Hash\": \"bftk7WuLjpE/AyP91y3gAA==\",\n  \"crc32c\": \"2513201517\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/bc45b1d1-ec5d-4b77-94d9-2ba6f83c9397.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_3610.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214300,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"55211b1a-aed6-429c-beb0-4591fce5cd59\"\n  ],\n  \"etag\": \"36NFb5INhPnLihjTyiWTEnL9QzM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.300Z\",\n  \"updated\": \"2024-10-02T01:56:54.300Z\",\n  \"size\": 21986,\n  \"md5Hash\": \"fQ+8l15CxfCttOB8N1GMhA==\",\n  \"crc32c\": \"718393283\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/c20333af-a455-4b2f-baf0-f852e684543b.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_103.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214210,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"dfe343c5-1bc6-4567-aeff-f4d0a5d97148\"\n  ],\n  \"etag\": \"6CTxiUS4DJ9N4idoFySr3sjqbJM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.210Z\",\n  \"updated\": \"2024-10-02T01:56:54.210Z\",\n  \"size\": 16046,\n  \"md5Hash\": \"FYAep0brMWsgA7+InkriWg==\",\n  \"crc32c\": \"3344683291\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/c27f43ab-cdee-4ea2-b993-85e04502d1e7.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_6098.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214347,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"ea270fe3-b6eb-47a8-99bd-210aef650f87\"\n  ],\n  \"etag\": \"fmiX1uFcsCJ+jFO+exx9d6bk/mk\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.347Z\",\n  \"updated\": \"2024-10-02T01:56:54.347Z\",\n  \"size\": 12401,\n  \"md5Hash\": \"EcuSTXTcjBC4z8pM5924Fw==\",\n  \"crc32c\": \"4120268992\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/c32a1d25-a547-46b5-9d66-b2cce1b62c6c.json",
    "content": "{\n  \"name\": \"mmembed/weather/0603.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213921,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"ddf8d7a7-8efa-49eb-9c53-faf58da8136d\"\n  ],\n  \"etag\": \"6Klsl62zA0WRA+G+4UCIY/vDkOI\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.921Z\",\n  \"updated\": \"2024-10-02T01:56:53.921Z\",\n  \"size\": 39151,\n  \"md5Hash\": \"XFlOrkJKdlNeVAoRT1pZRw==\",\n  \"crc32c\": \"3299132319\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/c340aa08-9ea1-4b1e-9155-0ffc9c35f7d4.json",
    "content": "{\n  \"name\": \"mmembed/weather/6094.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214146,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"031cccfa-463e-4292-9c85-035865a4c923\"\n  ],\n  \"etag\": \"KHCMGiWWYftxKez1KmpKWLs95n4\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.147Z\",\n  \"updated\": \"2024-10-02T01:56:54.147Z\",\n  \"size\": 20703,\n  \"md5Hash\": \"BD2FmGhaqEDG4AgLySPMSQ==\",\n  \"crc32c\": \"2369522850\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/c4005e1a-a820-4fc6-b4d7-36de986a6508.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_6097.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214339,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"045a66f1-581e-4f2e-90a2-bc9abbbb13c6\"\n  ],\n  \"etag\": \"J928/BcSJoJKjAa4Z6i5w7H57co\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.339Z\",\n  \"updated\": \"2024-10-02T01:56:54.339Z\",\n  \"size\": 11370,\n  \"md5Hash\": \"Her3EFrSZROZqHh9gakcqw==\",\n  \"crc32c\": \"3115868233\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/c65e9606-9e1a-4aa3-a546-fd07eaba08a3.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_4087.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214333,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"167a63a6-f99e-42bd-931b-d84f4cee54d2\"\n  ],\n  \"etag\": \"xl1+XYE5/IkN2pDWhbKQlWQRne0\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.333Z\",\n  \"updated\": \"2024-10-02T01:56:54.333Z\",\n  \"size\": 7235,\n  \"md5Hash\": \"EuQ+XU7TR7/XrlXs7RbbXg==\",\n  \"crc32c\": \"2331143951\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/ca44d9db-7998-4cf3-bc92-aa69d366900a.json",
    "content": "{\n  \"name\": \"mmembed/weather/0005.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213709,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"5f47ebf4-8104-4c77-b14d-ab64afaf4238\"\n  ],\n  \"etag\": \"1a7QnxXXuiPW7ifr699uW7Se7GM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.709Z\",\n  \"updated\": \"2024-10-02T01:56:53.709Z\",\n  \"size\": 103158,\n  \"md5Hash\": \"coVLb4VYMvHez7AwXu9UDA==\",\n  \"crc32c\": \"1998578397\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/cb8c98b6-f0f0-48d5-be12-e27ab75dbc89.json",
    "content": "{\n  \"name\": \"mmembed/weather/13.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213861,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"ca7353b0-aca6-46aa-b8e2-1d1a63ce1700\"\n  ],\n  \"etag\": \"TmyHQkMFOJdSMnhbzJes6nWt9EU\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.861Z\",\n  \"updated\": \"2024-10-02T01:56:53.861Z\",\n  \"size\": 28664,\n  \"md5Hash\": \"rfC3pi98hwF34muXwbHY1Q==\",\n  \"crc32c\": \"2304636867\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/cc2b2031-0170-43c4-bf8a-7a7267d38a96.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_2220.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214288,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"f11201fe-169b-4b49-b2a2-596c63e96c58\"\n  ],\n  \"etag\": \"IfF7r5o1QYWAQ1x7LtDfk0vXt4M\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.288Z\",\n  \"updated\": \"2024-10-02T01:56:54.288Z\",\n  \"size\": 16883,\n  \"md5Hash\": \"PcZIrAODfYqTBceSPRp1EQ==\",\n  \"crc32c\": \"2225075515\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/cea1146f-f63d-421e-b778-5ed8f39c1304.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_6100.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214343,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"1cd8b598-c0e9-484d-a338-f710f79f70d4\"\n  ],\n  \"etag\": \"iFxNKZw+aXVz3IH1RfgY9lyCLRk\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.343Z\",\n  \"updated\": \"2024-10-02T01:56:54.343Z\",\n  \"size\": 11489,\n  \"md5Hash\": \"SZ+oNWuKalnSLq9/4i3EHQ==\",\n  \"crc32c\": \"2645314601\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/cf2d1050-806b-4d32-9a18-083649e9142c.json",
    "content": "{\n  \"name\": \"mmembed/weather/2212.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213986,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"da6dd067-49ac-481e-8553-82725b8e3030\"\n  ],\n  \"etag\": \"bm3AEiIXwCULyhJ5EsiceK7mGlQ\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.986Z\",\n  \"updated\": \"2024-10-02T01:56:53.986Z\",\n  \"size\": 87960,\n  \"md5Hash\": \"hiUgPxskjlBw7f92X47JUg==\",\n  \"crc32c\": \"2886760861\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/d0920669-7709-4aab-97df-dd5e6c6719f9.json",
    "content": "{\n  \"name\": \"mmembed/weather/2219.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213996,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"7202c056-dd94-4bf5-95f8-9807d8e86ee9\"\n  ],\n  \"etag\": \"xYhGFz6dJnBr7QCRmiyj7DkJCjQ\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.996Z\",\n  \"updated\": \"2024-10-02T01:56:53.996Z\",\n  \"size\": 45089,\n  \"md5Hash\": \"zPaoT7+jycACMruM7pxnDw==\",\n  \"crc32c\": \"933722811\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/d12b9178-857a-4567-a7ef-3660f81813b9.json",
    "content": "{\n  \"name\": \"mmembed/weather/11.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213854,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"3a937106-e928-4170-86ac-2643395da56b\"\n  ],\n  \"etag\": \"XRoKS0Lw01rXHd7vhe+gZdjDZGY\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.854Z\",\n  \"updated\": \"2024-10-02T01:56:53.854Z\",\n  \"size\": 21367,\n  \"md5Hash\": \"uuNCC2Nljh+y1GIF2kCuRQ==\",\n  \"crc32c\": \"1983488918\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/d21827e3-c794-4fa8-8cbc-6b46bfff25d2.json",
    "content": "{\n  \"name\": \"mmembed/weather/4081.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214065,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"a91238a3-7419-421d-8e18-46455e786acb\"\n  ],\n  \"etag\": \"NF/eAMwkrTMgP7xb/POLES9IUfw\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.065Z\",\n  \"updated\": \"2024-10-02T01:56:54.065Z\",\n  \"size\": 6597,\n  \"md5Hash\": \"Rwy4/ScrQg7GgueQKmg0Pg==\",\n  \"crc32c\": \"1143024630\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/d223daa7-afa8-4108-8965-2ac9c45d9bab.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_3601.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214287,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"a9e8265d-6c6a-4792-81a0-2f511be639c2\"\n  ],\n  \"etag\": \"jVrNIINoY84Khvaaxa4HUR53toM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.287Z\",\n  \"updated\": \"2024-10-02T01:56:54.287Z\",\n  \"size\": 15366,\n  \"md5Hash\": \"Oir6BmhsoGnFG8yxZcnJZQ==\",\n  \"crc32c\": \"3272905219\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/d2efd3f2-cf9a-4d75-89ad-3c8d873a1ff1.json",
    "content": "{\n  \"name\": \"mmembed/weather/0594.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213903,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"e8779af5-4340-4e26-8442-53c59ceb4367\"\n  ],\n  \"etag\": \"dkxO4Uta7u0zJ654U1hTHW4Cyy4\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.903Z\",\n  \"updated\": \"2024-10-02T01:56:53.903Z\",\n  \"size\": 29193,\n  \"md5Hash\": \"aFP7fHpFOdqG7A3tJztLdQ==\",\n  \"crc32c\": \"2286470848\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/d35aeaf2-da34-4207-a602-a9b236d62fca.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0605.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214244,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"16e0535d-c5e1-4779-9a8c-10f760521af3\"\n  ],\n  \"etag\": \"h7LpkiU/X+BNGr1Kpc5oy3czXu8\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.244Z\",\n  \"updated\": \"2024-10-02T01:56:54.244Z\",\n  \"size\": 4906,\n  \"md5Hash\": \"vrLxhCy5zu8AhZ9JIRxUYA==\",\n  \"crc32c\": \"1969454390\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/d4bb491a-51aa-48ac-a946-faf94bdb98f3.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_2215.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214282,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"72914972-5f01-44a8-a4e9-0126a81ac336\"\n  ],\n  \"etag\": \"txqKDy+maH+HsnuxeKkzCoM7Wb0\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.282Z\",\n  \"updated\": \"2024-10-02T01:56:54.282Z\",\n  \"size\": 6439,\n  \"md5Hash\": \"OaVvrbMeiVm/43ES2iV0Gw==\",\n  \"crc32c\": \"2215478558\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/d51f715d-c5b7-485b-8718-7112b700252b.json",
    "content": "{\n  \"name\": \"mmembed/weather/6090.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214087,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"82dddfc7-4b81-4fdb-a27c-7bf0c590dee4\"\n  ],\n  \"etag\": \"/dARFH2VBTm2mQWNu46I4Dmup/c\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.087Z\",\n  \"updated\": \"2024-10-02T01:56:54.087Z\",\n  \"size\": 354485,\n  \"md5Hash\": \"OimSCcLu9nWf/uwwZOhb6g==\",\n  \"crc32c\": \"943340253\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/d62ac9e8-14ff-4a51-a65c-13b0be4a382d.json",
    "content": "{\n  \"name\": \"mmembed/weather/1839.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213958,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"bf3a70ae-4258-40eb-8961-4dd50e516564\"\n  ],\n  \"etag\": \"EQLzC1lQ855pOEC0RcPWyuJH+rE\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.958Z\",\n  \"updated\": \"2024-10-02T01:56:53.958Z\",\n  \"size\": 48804,\n  \"md5Hash\": \"yHgwbGeXk0FNqtDS5Ub/fw==\",\n  \"crc32c\": \"790104245\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/d7400d59-6e7d-438d-a433-7e248a89d7ef.json",
    "content": "{\n  \"name\": \"mmembed/weather/3603.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214013,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"5ec52623-7865-4f3f-94c0-c1d3d0c0418c\"\n  ],\n  \"etag\": \"Tleqou+fdj8dPyU4pCOZ9ek4NfQ\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.013Z\",\n  \"updated\": \"2024-10-02T01:56:54.013Z\",\n  \"size\": 151621,\n  \"md5Hash\": \"35aqUlO4R1cAJRin9HkQaQ==\",\n  \"crc32c\": \"2368290271\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/d8efb7b3-3d17-47f7-b46a-d4d5933d1c6c.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_117.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214221,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"62fc60bf-b576-4bc3-bd3e-d76f5595b921\"\n  ],\n  \"etag\": \"GKJQtIuyt7pzhjruWTQGHvtG2oc\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.221Z\",\n  \"updated\": \"2024-10-02T01:56:54.221Z\",\n  \"size\": 17090,\n  \"md5Hash\": \"aoMtIteS4QZaZydL8AxcLA==\",\n  \"crc32c\": \"804331978\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/daff4d68-e7ba-4876-a389-f6379792db29.json",
    "content": "{\n  \"name\": \"mmembed/weather/3601.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214007,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"8cd2b54b-ed19-4dd2-a2b3-51c011100ba8\"\n  ],\n  \"etag\": \"qi6wLtylcPhA9zHxsq/SajIxC/s\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.007Z\",\n  \"updated\": \"2024-10-02T01:56:54.007Z\",\n  \"size\": 141150,\n  \"md5Hash\": \"4HqU3t6UMtoOpkgSUuIqFA==\",\n  \"crc32c\": \"3086447202\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/db685a34-6e9f-480c-998f-74d9dfa33ded.json",
    "content": "{\n  \"name\": \"mmembed/weather/4077.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214055,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"421a54d0-8b3a-4cbb-9e24-5f505b1af491\"\n  ],\n  \"etag\": \"uVyrmsESamNGDFZWlZYI4XWWVcY\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.055Z\",\n  \"updated\": \"2024-10-02T01:56:54.055Z\",\n  \"size\": 15203,\n  \"md5Hash\": \"+qlt1Fgp8vcE8gDligxh0Q==\",\n  \"crc32c\": \"2806870315\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/dd83acba-c2c2-471f-96e6-02a7564ccdf2.json",
    "content": "{\n  \"name\": \"mmembed/\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834192461,\n  \"contentType\": \"application/octet-stream\",\n  \"storageClass\": \"STANDARD\",\n  \"downloadTokens\": [],\n  \"etag\": \"ayX4x+BBd+J+emwJyoNXbTcIBhI\",\n  \"timeCreated\": \"2024-10-02T01:56:32.461Z\",\n  \"updated\": \"2024-10-02T01:56:32.461Z\",\n  \"size\": 130,\n  \"md5Hash\": \"iUpceipDkyQhwwUflucD5w==\",\n  \"crc32c\": \"3108464614\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/ddd82ca8-addd-461f-a409-10ba4f2384f2.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0003.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214191,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"17d3f8ee-a231-40d9-96d4-0904ac6f6533\"\n  ],\n  \"etag\": \"sgF4xj300LFIm+OUNiQ4gi9ajpY\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.191Z\",\n  \"updated\": \"2024-10-02T01:56:54.191Z\",\n  \"size\": 9957,\n  \"md5Hash\": \"3KNWHuc+/ir68WtCD5v+kQ==\",\n  \"crc32c\": \"4190797600\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/de2c3001-b81a-43cb-bc8d-430cc5f31ecd.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_4077.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214316,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"af30d50b-9865-4e73-97ff-e6f7cc4806d7\"\n  ],\n  \"etag\": \"EZkweuozZEjAqDi1Pvvs2KY9Joo\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.316Z\",\n  \"updated\": \"2024-10-02T01:56:54.316Z\",\n  \"size\": 2643,\n  \"md5Hash\": \"47TdUAtbtN8EnfW5kzmgUQ==\",\n  \"crc32c\": \"374066348\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/df252df6-207a-4b5c-b429-517a9188442a.json",
    "content": "{\n  \"name\": \"mmembed/weather/0004.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213692,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"d726e1bd-4a84-4146-a4ba-333e542d303c\"\n  ],\n  \"etag\": \"ElR4aDdiTcjvrFGfNldjL81vOwQ\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.692Z\",\n  \"updated\": \"2024-10-02T01:56:53.692Z\",\n  \"size\": 268578,\n  \"md5Hash\": \"E0O72b9OrRxVatqL/eltOg==\",\n  \"crc32c\": \"3171152141\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/dfc781e1-8984-4900-9fd9-4d67a8d06165.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_109.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214214,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"de9da0bc-9a51-4a0c-a17f-6acf078ba550\"\n  ],\n  \"etag\": \"78ASXpAzbURV3vlLm3OeH48zjco\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.214Z\",\n  \"updated\": \"2024-10-02T01:56:54.214Z\",\n  \"size\": 17956,\n  \"md5Hash\": \"ClNBzqKKFk1bJAYkdNj2gg==\",\n  \"crc32c\": \"3706692027\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/e1a86ae0-794e-48c7-a521-6184fe47baf7.json",
    "content": "{\n  \"name\": \"mmembed/weather/0002.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213640,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"97096972-a13b-498a-becb-123ece865910\"\n  ],\n  \"etag\": \"n0rLNPaTGWhSPvm6eMhFwa2lYuw\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.640Z\",\n  \"updated\": \"2024-10-02T01:56:53.640Z\",\n  \"size\": 994298,\n  \"md5Hash\": \"T2Opq6gr2XUZjBLYnq6llQ==\",\n  \"crc32c\": \"515311822\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/e24996a9-4e92-47da-b164-afe68646772e.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_2209.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214268,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"6267b0fa-ffa8-45d9-8541-9cd22f60136c\"\n  ],\n  \"etag\": \"qZ2UTVf5GV7/OourB17h65lUn2s\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.268Z\",\n  \"updated\": \"2024-10-02T01:56:54.268Z\",\n  \"size\": 9457,\n  \"md5Hash\": \"RNOgPQzFyVbcU+ZT+i5zDQ==\",\n  \"crc32c\": \"2264066299\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/e4a1e47d-a111-4f8f-aa81-fa8305cacc02.json",
    "content": "{\n  \"name\": \"mmembed/weather/0597.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213916,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"8c62d4ed-0318-4cdd-b00f-3c443c12dc77\"\n  ],\n  \"etag\": \"5KbPghnnyClJrkkmFI+/KsrYsZ8\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.916Z\",\n  \"updated\": \"2024-10-02T01:56:53.916Z\",\n  \"size\": 27624,\n  \"md5Hash\": \"nTwnmDe/fqR1c90UeNq9IQ==\",\n  \"crc32c\": \"1903336660\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/e5411091-9430-440b-b167-06666aaa6282.json",
    "content": "{\n  \"name\": \"mmembed/weather/3604.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214016,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"b202a6e5-1997-4c37-bb8c-ec5da7bd450e\"\n  ],\n  \"etag\": \"oqLwosWonXHzGIiFggodFVGtrtY\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.016Z\",\n  \"updated\": \"2024-10-02T01:56:54.016Z\",\n  \"size\": 351273,\n  \"md5Hash\": \"VGyzpwlsUBUOXObxEU/0mw==\",\n  \"crc32c\": \"1145649718\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/e68b7951-4436-4b51-9352-b8478a7016a2.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_1833.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214249,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"4f33d30e-21b9-465e-a541-af3e5da598b9\"\n  ],\n  \"etag\": \"zXhP6zvyINMiSxE2OEGOL95TinM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.249Z\",\n  \"updated\": \"2024-10-02T01:56:54.249Z\",\n  \"size\": 117837,\n  \"md5Hash\": \"Vu1qLxQXM01/UAIvZ5Wf+g==\",\n  \"crc32c\": \"1750997445\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/e745dd10-3a36-4c3c-9f6d-a0df1af88db5.json",
    "content": "{\n  \"name\": \"mmembed/weather/0595.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213904,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"f05ce626-b1c0-4f02-8fa8-aad3fd56bde5\"\n  ],\n  \"etag\": \"L5oViZH6+8+Zd4Qike7vDMLuOc8\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.904Z\",\n  \"updated\": \"2024-10-02T01:56:53.904Z\",\n  \"size\": 92253,\n  \"md5Hash\": \"KFXa+aV8k1S7ah7X88n+jw==\",\n  \"crc32c\": \"2361688226\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/e7f85be0-c8af-4658-ae23-37f0fec2d3e8.json",
    "content": "{\n  \"name\": \"mmembed/weather/2208.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213970,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"23b0611d-5787-4918-97f8-baaa06644220\"\n  ],\n  \"etag\": \"5yw8IIgwgVTdHIyItcIwjk+yQUo\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.970Z\",\n  \"updated\": \"2024-10-02T01:56:53.970Z\",\n  \"size\": 138405,\n  \"md5Hash\": \"liitDDagNVKNGPaNzLLUbQ==\",\n  \"crc32c\": \"4107128285\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/e96f6208-3db5-4e59-92fd-13bcc17a219f.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0602.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214240,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"7ab54014-3153-40e8-b6e6-a82ac324832b\"\n  ],\n  \"etag\": \"qGFFzsAApg5Gi6t4u9Hj0Le4qzs\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.240Z\",\n  \"updated\": \"2024-10-02T01:56:54.240Z\",\n  \"size\": 3483,\n  \"md5Hash\": \"Q3t6WQluw8Dd23F6+LFkGw==\",\n  \"crc32c\": \"399480627\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/ec636ad8-59af-4d37-899d-7e38b6820142.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_107.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214213,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"9ebedfb7-474a-47b1-ac68-3de4766fbbde\"\n  ],\n  \"etag\": \"dQWmtJQ8yePogB7/M5zaSyzMrlA\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.213Z\",\n  \"updated\": \"2024-10-02T01:56:54.213Z\",\n  \"size\": 9910,\n  \"md5Hash\": \"G50vu3DNHA5YcDyvdQxFmQ==\",\n  \"crc32c\": \"2955303886\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/eed1b24c-8d17-474c-a8c5-abbb1253f3ac.json",
    "content": "{\n  \"name\": \"mmembed/weather/3611.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214042,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"c22cf7ae-7404-4b72-b552-4ccdd675d99d\"\n  ],\n  \"etag\": \"HpK7HZu1tTtnUJZVH76C7UOmMqg\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.042Z\",\n  \"updated\": \"2024-10-02T01:56:54.042Z\",\n  \"size\": 148153,\n  \"md5Hash\": \"4ck5RvQZzrl6c+ZxvNkCcQ==\",\n  \"crc32c\": \"89847283\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/efcf28ed-64c0-4fb0-889e-adb9f7826bd2.json",
    "content": "{\n  \"name\": \"mmembed/weather/0008.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213847,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"234f0eae-182a-4d83-bc39-2ae24a8ee829\"\n  ],\n  \"etag\": \"e3PSAfGufeNhOHQ5abxoSTHeb9k\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.847Z\",\n  \"updated\": \"2024-10-02T01:56:53.847Z\",\n  \"size\": 61422,\n  \"md5Hash\": \"wc/szoSdzggpcnfEdWJTFw==\",\n  \"crc32c\": \"1372160199\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/f0c05fe7-cce3-4ae7-81a7-04eb885a858d.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_6099.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214342,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"0110f873-681e-46bd-a17c-af2c5470df3e\"\n  ],\n  \"etag\": \"/TKfdm1sD/drN9Jtwc4RAKOnrgY\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.342Z\",\n  \"updated\": \"2024-10-02T01:56:54.342Z\",\n  \"size\": 12333,\n  \"md5Hash\": \"kxfalBV6Tbp8PCY4BdzptA==\",\n  \"crc32c\": \"166952124\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/f1ee8d78-1966-45ac-899b-0fb7fdbef35c.json",
    "content": "{\n  \"name\": \"mmembed/weather/0007.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213844,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"364ace97-f6c4-4a89-bdd3-ceadabca171d\"\n  ],\n  \"etag\": \"wPC/OdvNOe9dIfWOqT48SEgJ4JY\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.844Z\",\n  \"updated\": \"2024-10-02T01:56:53.844Z\",\n  \"size\": 109689,\n  \"md5Hash\": \"jS9TP2/gdzJRyjozsygCbA==\",\n  \"crc32c\": \"2377344493\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/f1fdb37a-b154-4165-9964-31831b40be8b.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_1836.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214262,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"a22b1ef2-13ab-4438-a9fa-e4a75e965820\"\n  ],\n  \"etag\": \"3eMxUxstMF3Hc1tKwDSYhFTBxhM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.262Z\",\n  \"updated\": \"2024-10-02T01:56:54.262Z\",\n  \"size\": 6577,\n  \"md5Hash\": \"h4gCTlZNWFNtW2NE4I8Zag==\",\n  \"crc32c\": \"2660436048\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/f45cae23-5852-45c2-a36d-923768351808.json",
    "content": "{\n  \"name\": \"mmembed/weather/1836.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213962,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"199decca-f07c-4998-b187-01a68827156e\"\n  ],\n  \"etag\": \"k7aDcM+B3FPiQm6eyVskhuj395w\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.962Z\",\n  \"updated\": \"2024-10-02T01:56:53.962Z\",\n  \"size\": 97815,\n  \"md5Hash\": \"WDvRB2t9/RD0NwIS2UyGfg==\",\n  \"crc32c\": \"1122591655\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/f4c6688d-2e98-47bb-9880-d8beb1569036.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_4078.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214318,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"2ab5c197-80ff-4ce9-9348-6ff0423e87c1\"\n  ],\n  \"etag\": \"gwXdEYvBXc5rakSCyHx71vGM23s\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.319Z\",\n  \"updated\": \"2024-10-02T01:56:54.319Z\",\n  \"size\": 89931,\n  \"md5Hash\": \"Gv4IlotCjxBzfhU8RAbFJw==\",\n  \"crc32c\": \"3546163365\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/f511acae-1e9a-4fea-a426-acb3fd6aa5bd.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_2208.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214266,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"82134ac0-ede8-4060-a5cc-32dfb99c1c84\"\n  ],\n  \"etag\": \"Eu73F/4MCUTloAKnoQnrJilX7qc\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.266Z\",\n  \"updated\": \"2024-10-02T01:56:54.266Z\",\n  \"size\": 6790,\n  \"md5Hash\": \"ummzIVPkNgez6Y8lQaZXBg==\",\n  \"crc32c\": \"421390539\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/f699b6ac-7812-4564-adb5-8260b225f33b.json",
    "content": "{\n  \"name\": \"mmembed/weather/0600.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213912,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"299a3fe9-dc35-42c6-9f60-d0175acc1c36\"\n  ],\n  \"etag\": \"NamNw9xanNYJU6FtMv+fhCahg8Y\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.912Z\",\n  \"updated\": \"2024-10-02T01:56:53.912Z\",\n  \"size\": 22833,\n  \"md5Hash\": \"YNV79x4rYqcB5McZD5UEvQ==\",\n  \"crc32c\": \"2987221961\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/f77367b4-cad3-4d6a-961a-f4f4d6e24817.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_3611.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214301,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"29a7bb52-2744-4f7d-a6c1-20c420ed0e60\"\n  ],\n  \"etag\": \"ox7tjvspqEx5CpC59uDIXqfLqcM\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.301Z\",\n  \"updated\": \"2024-10-02T01:56:54.301Z\",\n  \"size\": 18725,\n  \"md5Hash\": \"LqnbIi+p/fujCbzPulrSpQ==\",\n  \"crc32c\": \"1305219629\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/f7bae092-4e7b-40f5-adae-a5c89bd9065a.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_3609.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214297,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"4f3f8dec-b8a8-402f-8a10-8393daed219c\"\n  ],\n  \"etag\": \"vbVYWrHuZgsQPVTGs2RL3I5jsEw\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.298Z\",\n  \"updated\": \"2024-10-02T01:56:54.298Z\",\n  \"size\": 25807,\n  \"md5Hash\": \"kpMkn4WbZy+y67COK0YT1Q==\",\n  \"crc32c\": \"3389225521\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/f8c2b960-f80e-488d-8126-04fe12057048.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_2217.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214277,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"80659f65-af8a-49e7-a403-229ee7248b6a\"\n  ],\n  \"etag\": \"auX1cmVBD/17tF27I7zZTyx4VjE\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.277Z\",\n  \"updated\": \"2024-10-02T01:56:54.277Z\",\n  \"size\": 9798,\n  \"md5Hash\": \"623r5r2OYWMUo4EAkFc9Sw==\",\n  \"crc32c\": \"3303942445\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/f9d73365-1492-4efa-aabd-b730650157d0.json",
    "content": "{\n  \"name\": \"mmembed/weather/6091.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214142,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"f36a924b-9590-404d-b3cc-25d096c80ef3\"\n  ],\n  \"etag\": \"otH8ryTrbcUPweJkX30T6+NENG4\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.142Z\",\n  \"updated\": \"2024-10-02T01:56:54.142Z\",\n  \"size\": 473182,\n  \"md5Hash\": \"HRFu0DP4ppNJ/h6uLcizVQ==\",\n  \"crc32c\": \"2590545155\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/faf1a8f8-0cdf-43ba-8af8-d53a2d1a060f.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_2213.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214273,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"d5c88b3c-7d95-4292-a53e-3ad1e4e2fd21\"\n  ],\n  \"etag\": \"HEhNtDjdOdXEGTPa1cYPbgTK2qQ\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.273Z\",\n  \"updated\": \"2024-10-02T01:56:54.273Z\",\n  \"size\": 9482,\n  \"md5Hash\": \"mCokVQHWq4xMuESyhRxU8A==\",\n  \"crc32c\": \"3854925524\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/fb7828b5-0a12-46a7-8c5c-6c220d116e36.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_3600.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214285,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"69b91dc4-9aaa-427e-a0bb-ea5dbdf86ab3\"\n  ],\n  \"etag\": \"fDFo7hiOZhVQmmQg1t0QCO1vtdY\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.285Z\",\n  \"updated\": \"2024-10-02T01:56:54.285Z\",\n  \"size\": 20827,\n  \"md5Hash\": \"52ygQ5oknPf8KC2vYOPXlQ==\",\n  \"crc32c\": \"313767107\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/fb857ec0-d80d-4404-ba0e-1ba436305d7d.json",
    "content": "{\n  \"name\": \"mmembed/weather/107.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834213874,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"34e5ae19-7e16-4cca-94b5-69e13cd7b6cb\"\n  ],\n  \"etag\": \"LVAh28zg+Q3Fd0qNfDsAbcweWmE\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:53.874Z\",\n  \"updated\": \"2024-10-02T01:56:53.874Z\",\n  \"size\": 14173,\n  \"md5Hash\": \"mb0IxY5F9+3Kf7FX1tFbyg==\",\n  \"crc32c\": \"916211899\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/fda16ac8-d1cf-49db-93b7-833c274cd0d2.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0002.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214181,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"03dc0c6e-5a04-4e89-b08e-424ff14ed3a3\"\n  ],\n  \"etag\": \"sUdOx7ysNF6/4uLSVHnYsbKCPgU\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.181Z\",\n  \"updated\": \"2024-10-02T01:56:54.181Z\",\n  \"size\": 20946,\n  \"md5Hash\": \"zUclulpNZq9JVCi/wuS9Ng==\",\n  \"crc32c\": \"383777366\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/fe3a8d7c-3dff-489e-abca-0b515db9420c.json",
    "content": "{\n  \"name\": \"mmembed/weather/3613.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214045,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"71cad1c1-a5cf-4e94-9f94-a7114d41a51b\"\n  ],\n  \"etag\": \"ofe8a3Q2JwBFk4Fk8J8dekX4QLA\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.045Z\",\n  \"updated\": \"2024-10-02T01:56:54.045Z\",\n  \"size\": 98048,\n  \"md5Hash\": \"TyLhWmabzDIMkTXgD8iZYA==\",\n  \"crc32c\": \"2412710610\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/fe78e904-37eb-46de-9195-1264c9acc754.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_1831.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214253,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"8229a7ab-303e-4e82-887a-e3813b208c7c\"\n  ],\n  \"etag\": \"cnfSjZhufQ/KkRsrUGMin1dNs5c\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.253Z\",\n  \"updated\": \"2024-10-02T01:56:54.253Z\",\n  \"size\": 4722,\n  \"md5Hash\": \"iGOsNNCm3jRXGYxUeq8LYQ==\",\n  \"crc32c\": \"1068360269\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/ff05e63d-c45b-499f-bc6e-a507047b4f10.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_1837.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214257,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"3f56d363-1b8e-45d7-88c6-1e3f45134d98\"\n  ],\n  \"etag\": \"YoIcCxwwznBhzKpfuaAWU+0o9JI\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.257Z\",\n  \"updated\": \"2024-10-02T01:56:54.257Z\",\n  \"size\": 8050,\n  \"md5Hash\": \"07Mdj3eeTssVcZle2Py4AA==\",\n  \"crc32c\": \"1913399654\"\n}"
  },
  {
    "path": "multimodal-embeddings/emulator-export/storage_export/metadata/ffe18889-8e9a-4b8c-8a81-867a03a3c274.json",
    "content": "{\n  \"name\": \"mmembed/weather/thumb_0592.jpg\",\n  \"bucket\": \"mm-demo.appspot.com\",\n  \"metageneration\": 1,\n  \"generation\": 1727834214219,\n  \"contentType\": \"image/jpeg\",\n  \"storageClass\": \"STANDARD\",\n  \"contentDisposition\": \"inline\",\n  \"downloadTokens\": [\n    \"5dfdf3fb-b484-44cf-855c-af26b4becbd1\"\n  ],\n  \"etag\": \"3qmL79mafL+Jtj6cVAmtJJg2GQQ\",\n  \"customMetadata\": {},\n  \"timeCreated\": \"2024-10-02T01:56:54.220Z\",\n  \"updated\": \"2024-10-02T01:56:54.220Z\",\n  \"size\": 8465,\n  \"md5Hash\": \"Qf9ha260CKNVTVrJQf1u/w==\",\n  \"crc32c\": \"334738737\"\n}"
  },
  {
    "path": "multimodal-embeddings/eslint.config.js",
    "content": "import js from '@eslint/js';\nimport ts from 'typescript-eslint';\nimport svelte from 'eslint-plugin-svelte';\nimport prettier from 'eslint-config-prettier';\nimport globals from 'globals';\n\n/** @type {import('eslint').Linter.FlatConfig[]} */\nexport default [\n\tjs.configs.recommended,\n\t...ts.configs.recommended,\n\t...svelte.configs['flat/recommended'],\n\tprettier,\n\t...svelte.configs['flat/prettier'],\n\t{\n\t\tlanguageOptions: {\n\t\t\tglobals: {\n\t\t\t\t...globals.browser,\n\t\t\t\t...globals.node\n\t\t\t}\n\t\t}\n\t},\n\t{\n\t\tfiles: ['**/*.svelte'],\n\t\tlanguageOptions: {\n\t\t\tparserOptions: {\n\t\t\t\tparser: ts.parser\n\t\t\t}\n\t\t}\n\t},\n\t{\n\t\tignores: ['build/', '.svelte-kit/', 'dist/']\n\t}\n];\n"
  },
  {
    "path": "multimodal-embeddings/fb/.firebaserc",
    "content": "{\n\t\"projects\": {\n\t\t\"default\": \"\"\n\t}\n}\n"
  },
  {
    "path": "multimodal-embeddings/fb/README.md",
    "content": "# Firebase Function for Embedding\n\nThis Function was responsible for watching our Firebase storage bucket and sending any images uploaded to the Gemini Multimodal Embeddings API, storing those embeddings in a Vector in our Firestore database. Read more about Cloud Functions [here](https://firebase.google.com/docs/functions).\n\nFor more information on how Firebase handles embeddings, check out [Search with Vector Search](https://firebase.google.com/docs/firestore/vector-search) in the Firebase docs, which this code is based on.\n\n`functions/src/index.ts` has lots of comments in it so you can follow along, though in order to keep our sanity and have the embedding code in one place, it imports `embedder.ts` from the main `/src/lib` directory.\n\nMake sure to update the `.firebaserc` file with your project name, or just use `firebase init` from the cli to create a new project to deploy to.\n\nLike any other Node project, make sure to run `npm i` before testing anything, like local emulation with `npm run serve`\n\n### Gemini Labeling\n\nThere's also some code in `functions/src/gemini.ts` that attempted to add labels to our embeddings, sending each image to Gemini and getting back a description. Useful for learning about the multimodal capabilities of Gemini, but was ultimately not necessary for our experiments.\n\nIt requires the same API key you generated for the main project.\n\n> It's worth noting: The MM Embedding API can take in text, image, and video, but it doesn't _automatically_ combine them in any meaningful way. You can, of course, add those vectors together yourself after the fact, to give you a new vector of those combined objects.\n"
  },
  {
    "path": "multimodal-embeddings/fb/firebase.json",
    "content": "{\n\t\"functions\": [\n\t\t{\n\t\t\t\"source\": \"functions\",\n\t\t\t\"codebase\": \"default\",\n\t\t\t\"runtime\": \"nodejs20\",\n\t\t\t\"ignore\": [\"node_modules\", \".git\", \"firebase-debug.log\", \"firebase-debug.*.log\", \"*.local\"],\n\t\t\t\"predeploy\": [\"npm --prefix \\\"$RESOURCE_DIR\\\" run build\"]\n\t\t}\n\t],\n\t\"storage\": {\n\t\t\"rules\": \"storage.rules\"\n\t}\n}\n"
  },
  {
    "path": "multimodal-embeddings/fb/functions/package.json",
    "content": "{\n\t\"name\": \"functions\",\n\t\"scripts\": {\n\t\t\"build\": \"tsc\",\n\t\t\"build:watch\": \"tsc --watch\",\n\t\t\"serve\": \"npm run build && firebase emulators:start\",\n\t\t\"shell\": \"npm run build && firebase functions:shell\",\n\t\t\"start\": \"npm run shell\",\n\t\t\"deploy\": \"firebase deploy --only functions\",\n\t\t\"logs\": \"firebase functions:log\"\n\t},\n\t\"main\": \"lib/index.js\",\n\t\"dependencies\": {\n\t\t\"@google-cloud/aiplatform\": \"^3.23.0\",\n\t\t\"@google/generative-ai\": \"^0.12.0\",\n\t\t\"firebase-admin\": \"^12.1.1\",\n\t\t\"firebase-functions\": \"^5.0.0\",\n\t\t\"path\": \"^0.12.7\",\n\t\t\"sharp\": \"^0.33.4\"\n\t},\n\t\"devDependencies\": {\n\t\t\"firebase-functions-test\": \"^3.1.0\",\n\t\t\"typescript\": \"^4.9.0\"\n\t},\n\t\"private\": true\n}\n"
  },
  {
    "path": "multimodal-embeddings/fb/functions/src/gemini.ts",
    "content": "import { GoogleGenerativeAI } from '@google/generative-ai';\n\nconst genAI = new GoogleGenerativeAI(''); // key from your console or .env file\nconst model = genAI.getGenerativeModel({\n\tmodel: 'gemini-1.5-pro-latest'\n});\n\nfunction getImagePart(buffer, mimeType) {\n\treturn {\n\t\tinlineData: {\n\t\t\tdata: Buffer.from(buffer).toString('base64'),\n\t\t\tmimeType\n\t\t}\n\t};\n}\n\nexport const getImageLabel = async (imageBuffer: Buffer, mimeType: string) => {\n\tconsole.log('getImageLabel', mimeType);\n\tconst imagePart = getImagePart(imageBuffer, mimeType);\n\tconst prompt =\n\t\t'Generate a concise description for this image, to be used in the generation of vector embeddings.';\n\ttry {\n\t\tconst content = await model.generateContent([imagePart, prompt]);\n\t\tconst text = content.response.text();\n\t\tconsole.log('Generated Label:', text);\n\t\treturn text;\n\t} catch (err) {\n\t\tconsole.error(err);\n\t\tthrow err;\n\t}\n};\n"
  },
  {
    "path": "multimodal-embeddings/fb/functions/src/index.ts",
    "content": "import { onObjectFinalized } from 'firebase-functions/v2/storage';\nimport { getFirestore, FieldValue } from 'firebase-admin/firestore';\nimport { initializeApp } from 'firebase-admin/app';\nimport { getStorage } from 'firebase-admin/storage';\nimport { getEmbeddings } from './../../../src/lib/embedder';\nimport { getImageLabel } from './gemini';\nimport * as logger from 'firebase-functions/logger';\nimport * as path from 'path';\nimport * as sharp from 'sharp';\n\n// For storage access\ninitializeApp();\nconst fs = getFirestore(''); // your firestore database\nconst bucketName = ''; // your bucket name\nconst bucketWithPrefix = `gs://${bucketName}`;\n\nconst mmEmbedBasePath = 'mmembed'; // path we look at for files to be added\nconst mmEmbedWLabelBasePath = 'mmembed-labeled';\n\nexport const onFileUploaded = onObjectFinalized(\n\t{ cpu: 2, memory: '2GiB', bucket: bucketName },\n\tasync (event) => {\n\t\tconst fileBucket = event.data.bucket; // Storage bucket containing the file.\n\t\tconst filePath = event.data.name; // File path in the bucket.\n\t\tconst contentType = event.data.contentType || ''; // File content type.\n\n\t\tlogger.info('bucketPrefix!', bucketWithPrefix);\n\t\tlogger.info('File uploaded!', filePath, contentType);\n\n\t\t// Exit if this is triggered on a file that is not an image.\n\t\tif (!contentType.startsWith('image/')) {\n\t\t\treturn logger.warn('This is not an image.');\n\t\t}\n\t\t// Exit if the image is already a thumbnail.\n\t\tconst fileName = path.basename(filePath);\n\t\tif (fileName.startsWith('thumb_')) {\n\t\t\treturn logger.warn('Already a Thumbnail.');\n\t\t}\n\n\t\t// Make sure we only act on mmembed uploads\n\t\tconst dirs = path.dirname(filePath).split('/');\n\t\tif (dirs.length && !dirs[0].startsWith(mmEmbedBasePath)) {\n\t\t\treturn logger.warn('Not a mmembed-* upload.');\n\t\t}\n\n\t\t// Create thumbnail -> maybe label -> embed -> store\n\t\t// Error out if any of these steps fail\n\t\ttry {\n\t\t\tconst { imageBuffer, thumbFilePath } = await createThumbnail(\n\t\t\t\tfileBucket,\n\t\t\t\tfilePath,\n\t\t\t\tcontentType\n\t\t\t);\n\t\t\tconsole.log('thumbpath:', thumbFilePath);\n\n\t\t\tlet embedResult;\n\t\t\tlet suffix = '';\n\t\t\tlet label = '';\n\t\t\tif (dirs[0] == mmEmbedWLabelBasePath) {\n\t\t\t\t// -labeled folder upload, so get gemini to label the image!\n\t\t\t\tconsole.log(`content: ${contentType} typeof ${typeof contentType}`);\n\t\t\t\tlabel = await getImageLabel(imageBuffer, contentType);\n\t\t\t\tembedResult = await getEmbeddings({ text: label, imageBytes: imageBuffer });\n\t\t\t\tsuffix = '-labeled';\n\t\t\t} else {\n\t\t\t\t// only an image\n\t\t\t\tembedResult = await getEmbeddings({ imageBytes: imageBuffer });\n\t\t\t}\n\t\t\tconsole.log('EmbededImage result', embedResult);\n\n\t\t\tconst storeResult = await storeEmbeddings(\n\t\t\t\tfilePath,\n\t\t\t\tthumbFilePath,\n\t\t\t\tembedResult.imageEmbeddings,\n\t\t\t\tdirs[1] + suffix, // dir name and whether or not its labeled, used as collection\n\t\t\t\tlabel\n\t\t\t);\n\t\t\tconsole.log('Creation success!', storeResult.id);\n\t\t} catch (err) {\n\t\t\tconsole.error('Failed to store embeddings for image.', err);\n\t\t\treturn;\n\t\t}\n\t}\n);\n\nconst storeEmbeddings = async (\n\tfilePath: string,\n\tthumbPath: string,\n\tembeddings: number[],\n\tcollectionName: string,\n\tlabel: string = ''\n) => {\n\tconst coll = fs.collection(collectionName);\n\tconst data = {\n\t\tfilePath,\n\t\tthumbPath,\n\t\tembeddings: FieldValue.vector(embeddings)\n\t};\n\tif (label) {\n\t\tdata['label'] = label;\n\t}\n\treturn await coll.add(data);\n};\n\nconst createThumbnail = async (fileBucket: string, filePath: string, contentType: string) => {\n\tconst fileName = path.basename(filePath);\n\n\t// Download file into memory from bucket.\n\tconst bucket = getStorage().bucket(fileBucket);\n\tconst downloadResponse = await bucket.file(filePath).download();\n\tconst imageBuffer = downloadResponse[0];\n\n\tconst thumbnailBuffer = await sharp(imageBuffer)\n\t\t.resize({\n\t\t\theight: 200,\n\t\t\tfit: 'contain',\n\t\t\twithoutEnlargement: true\n\t\t})\n\t\t.toBuffer();\n\n\t// Prefix 'thumb_' to file name.\n\tconst thumbFileName = `thumb_${fileName}`;\n\tconst thumbFilePath = path.join(path.dirname(filePath), thumbFileName);\n\n\t// Upload the thumbnail.\n\tconst metadata = { contentType };\n\tawait bucket.file(thumbFilePath).save(thumbnailBuffer, {\n\t\tmetadata\n\t});\n\tlogger.log(`Thumbnail uploaded! ${thumbFilePath}}`);\n\treturn { imageBuffer, thumbFilePath };\n};\n"
  },
  {
    "path": "multimodal-embeddings/fb/functions/tsconfig.json",
    "content": "{\n\t\"compilerOptions\": {\n\t\t\"module\": \"commonjs\",\n\t\t\"noImplicitReturns\": true,\n\t\t\"noUnusedLocals\": true,\n\t\t\"outDir\": \"lib\",\n\t\t\"sourceMap\": true,\n\t\t\"strict\": false,\n\t\t\"target\": \"es2017\"\n\t},\n\t\"compileOnSave\": true,\n\t\"include\": [\"src\"]\n}\n"
  },
  {
    "path": "multimodal-embeddings/fb/storage.rules",
    "content": "rules_version = '2';\n\n// Craft rules based on data in your Firestore database\n// allow write: if firestore.get(\n//    /databases/(default)/documents/users/$(request.auth.uid)).data.isAdmin;\nservice firebase.storage {\n  match /b/{bucket}/o {\n    match /{allPaths=**} {\n      allow read: if true;\n      allow write: if false;\n    }\n  }\n}\n"
  },
  {
    "path": "multimodal-embeddings/package.json",
    "content": "{\n\t\"name\": \"mm-embeddings\",\n\t\"version\": \"0.0.1\",\n\t\"private\": true,\n\t\"scripts\": {\n\t\t\"dev\": \"vite dev\",\n\t\t\"dev:emulate\": \"concurrently -n 'mmembed,firebase' -c 'blue,yellow' 'USE_EM=true vite dev' 'npm run emulate'\",\n\t\t\"emulate\": \"firebase emulators:start --import=emulator-export\",\n\t\t\"build\": \"vite build\",\n\t\t\"preview\": \"vite preview\",\n\t\t\"check\": \"svelte-kit sync && svelte-check --tsconfig ./tsconfig.json\",\n\t\t\"check:watch\": \"svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch\",\n\t\t\"lint\": \"prettier --check . && eslint .\",\n\t\t\"format\": \"prettier --write .\",\n\t\t\"deploy\": \"vite build && gcloud app deploy --version dev build/app.yaml --no-promote --quiet\",\n\t\t\"deploy:prod\": \"vite build && gcloud app deploy --version prod build/app.yaml\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@sveltejs/kit\": \"^2.0.0\",\n\t\t\"@sveltejs/vite-plugin-svelte\": \"^3.0.0\",\n\t\t\"@tailwindcss/typography\": \"^0.5.13\",\n\t\t\"@types/eslint\": \"^8.56.7\",\n\t\t\"@types/three\": \"^0.165.0\",\n\t\t\"autoprefixer\": \"^10.4.19\",\n\t\t\"concurrently\": \"^9.0.1\",\n\t\t\"eslint\": \"^9.0.0\",\n\t\t\"eslint-config-prettier\": \"^9.1.0\",\n\t\t\"eslint-plugin-svelte\": \"^2.36.0\",\n\t\t\"globals\": \"^15.0.0\",\n\t\t\"postcss\": \"^8.4.38\",\n\t\t\"prettier\": \"^3.1.1\",\n\t\t\"prettier-plugin-svelte\": \"^3.1.2\",\n\t\t\"prettier-plugin-tailwindcss\": \"^0.5.14\",\n\t\t\"svelte\": \"^4.2.7\",\n\t\t\"svelte-adapter-appengine\": \"^1.1.0\",\n\t\t\"svelte-check\": \"^3.6.0\",\n\t\t\"tailwindcss\": \"^3.4.3\",\n\t\t\"tslib\": \"^2.4.1\",\n\t\t\"typescript\": \"^5.0.0\",\n\t\t\"typescript-eslint\": \"^8.0.0-alpha.20\",\n\t\t\"vite\": \"^5.0.3\"\n\t},\n\t\"type\": \"module\",\n\t\"browserify\": {\n\t\t\"transform\": [\n\t\t\t\"cwise\"\n\t\t]\n\t},\n\t\"dependencies\": {\n\t\t\"@google-cloud/aiplatform\": \"^3.23.0\",\n\t\t\"@google-cloud/firestore\": \"^7.8.0\",\n\t\t\"@google-cloud/vertexai\": \"^1.2.0\",\n\t\t\"@tensorflow/tfjs-tsne\": \"^0.2.0\",\n\t\t\"@threlte/core\": \"^7.3.0\",\n\t\t\"@threlte/extras\": \"^8.11.2\",\n\t\t\"bits-ui\": \"^0.21.10\",\n\t\t\"clsx\": \"^2.1.1\",\n\t\t\"dat.gui\": \"^0.7.9\",\n\t\t\"dotenv\": \"^16.4.5\",\n\t\t\"firebase\": \"^10.12.2\",\n\t\t\"lucide-svelte\": \"^0.396.0\",\n\t\t\"sharp\": \"^0.33.4\",\n\t\t\"svelte-radix\": \"^1.1.0\",\n\t\t\"svelte-tweakpane-ui\": \"^1.3.0\",\n\t\t\"tailwind-merge\": \"^2.3.0\",\n\t\t\"tailwind-variants\": \"^0.2.1\",\n\t\t\"three\": \"^0.165.0\",\n\t\t\"umap-js\": \"^1.4.0\"\n\t}\n}\n"
  },
  {
    "path": "multimodal-embeddings/postcss.config.js",
    "content": "export default {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {}\n  }\n};"
  },
  {
    "path": "multimodal-embeddings/src/app.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer base {\n\t:root {\n\t\t--background: 0 0% 100%;\n\t\t--foreground: 20 14.3% 4.1%;\n\n\t\t--muted: 60 4.8% 95.9%;\n\t\t--muted-foreground: 25 5.3% 44.7%;\n\n\t\t--popover: 0 0% 100%;\n\t\t--popover-foreground: 20 14.3% 4.1%;\n\n\t\t--card: 0 0% 100%;\n\t\t--card-foreground: 20 14.3% 4.1%;\n\n\t\t--border: 20 5.9% 90%;\n\t\t--input: 20 5.9% 90%;\n\n\t\t--primary: 24 9.8% 10%;\n\t\t--primary-foreground: 60 9.1% 97.8%;\n\n\t\t--secondary: 60 4.8% 95.9%;\n\t\t--secondary-foreground: 24 9.8% 10%;\n\n\t\t--accent: 60 4.8% 95.9%;\n\t\t--accent-foreground: 24 9.8% 10%;\n\n\t\t--destructive: 0 72.2% 50.6%;\n\t\t--destructive-foreground: 60 9.1% 97.8%;\n\n\t\t--ring: 20 14.3% 4.1%;\n\n\t\t--radius: 0.5rem;\n\n\t\t--bard-color-brand-text-gradient-stop-1: #4285f4;\n\t\t--bard-color-brand-text-gradient-stop-1-rgb: 66, 133, 244;\n\t\t--bard-color-brand-text-gradient-stop-2: #9b72cb;\n\t\t--bard-color-brand-text-gradient-stop-2-rgb: 155, 114, 203;\n\t\t--bard-color-brand-text-gradient-stop-3: #d96570;\n\t\t--bard-color-brand-text-gradient-stop-3-rgb: 217, 101, 112;\n\t}\n\n\t.dark {\n\t\t--background: 20 14.3% 4.1%;\n\t\t--foreground: 60 9.1% 97.8%;\n\n\t\t--muted: 12 6.5% 15.1%;\n\t\t--muted-foreground: 24 5.4% 63.9%;\n\n\t\t--popover: 20 14.3% 4.1%;\n\t\t--popover-foreground: 60 9.1% 97.8%;\n\n\t\t--card: 20 14.3% 4.1%;\n\t\t--card-foreground: 60 9.1% 97.8%;\n\n\t\t--border: 12 6.5% 15.1%;\n\t\t--input: 12 6.5% 15.1%;\n\n\t\t--primary: 60 9.1% 97.8%;\n\t\t--primary-foreground: 24 9.8% 10%;\n\n\t\t--secondary: 12 6.5% 15.1%;\n\t\t--secondary-foreground: 60 9.1% 97.8%;\n\n\t\t--accent: 12 6.5% 15.1%;\n\t\t--accent-foreground: 60 9.1% 97.8%;\n\n\t\t--destructive: 0 62.8% 30.6%;\n\t\t--destructive-foreground: 60 9.1% 97.8%;\n\n\t\t--ring: 24 5.7% 82.9%;\n\t}\n}\n\n@layer base {\n\t* {\n\t\t@apply border-border;\n\t}\n\n\tbody {\n\t\t@apply dark bg-background text-foreground;\n\t\tfont-family: 'Gill Sans', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif;\n\t\tfont-size: 1.4rem;\n\t}\n}\n"
  },
  {
    "path": "multimodal-embeddings/src/app.d.ts",
    "content": "// See https://kit.svelte.dev/docs/types#app\n// for information about these interfaces\ndeclare global {\n\tnamespace App {\n\t\t// interface Error {}\n\t\t// interface Locals {}\n\t\t// interface PageData {}\n\t\t// interface PageState {}\n\t\t// interface Platform {}\n\t}\n}\n\nexport {};\n"
  },
  {
    "path": "multimodal-embeddings/src/app.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>Gemini MM Embeddings - CL Demos</title>\n\t\t<link rel=\"icon\" href=\"%sveltekit.assets%/favicon.png\" />\n\t\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n\t\t%sveltekit.head%\n\t</head>\n\t<body data-sveltekit-preload-data=\"hover\">\n\t\t<div style=\"display: contents\">%sveltekit.body%</div>\n\t</body>\n</html>\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/api.ts",
    "content": "import * as THREE from 'three';\n\nexport type StoredImage = {\n\tembeddings: number[];\n\tfilePath: string;\n\tthumbPath: string;\n\tlabel?: string;\n};\n\nexport type EmbeddingRequest = {\n\timageBytes?: Buffer | string;\n\ttext?: string;\n};\n\nexport type SearchRequest = {\n\tcollection: string;\n\tembeddings: number[];\n};\n\nexport type SearchResponse = {\n\tembeddings: number[];\n\tsearchResults: StoredImage[];\n};\n\nexport type Collection = {\n\tid: string;\n\tpath: string;\n};\n\n// typescript helper since all threlte events use it and its not exported\nexport type InteractionEvent = THREE.Intersection & {\n\tintersections: THREE.Intersection[]; // The first intersection of each intersected object\n\tobject: THREE.Object3D; // The object that was actually hit\n\teventObject: THREE.Object3D; // The object that registered the event\n\tcamera: THREE.Camera; // The camera used for raycasting\n\tdelta: THREE.Vector2; //  Distance between mouse down and mouse up event in pixels\n\tnativeEvent: MouseEvent | PointerEvent | WheelEvent; // The native browser event\n\tpointer: THREE.Vector2; // The pointer position in normalized device coordinates\n\tray: THREE.Ray; // The ray used for raycasting\n\tstopPropagation: () => void; // Function to stop propagation of the event\n\tstopped: Boolean; // Whether the event propagation has been stopped\n};\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/cloud-firebase.ts",
    "content": "/**\n * Yes, there is another firebase.ts in $lib/ but this one has to use\n * special `@google-cloud/firestore` package instead, which contains\n * the necessary VectorQuery findNearest() method and a simple listCollections() method\n *\n * Sadly, this library completely ignores the emulator API, so we can't include\n * data for you to test with specific collections. It will always try to reach\n * your prod Firestore database online.\n */\nimport { Firestore, FieldValue } from '@google-cloud/firestore';\nimport { FIRESTORE_DB_ID } from './consts';\nimport type { VectorQuery, VectorQuerySnapshot } from '@google-cloud/firestore';\n\nconst db = new Firestore({ databaseId: FIRESTORE_DB_ID });\n\nexport const listCollections = async () => {\n\ttry {\n\t\tconst collections = await db.listCollections();\n\t\treturn collections.map((collection) => {\n\t\t\treturn { id: collection.id, path: collection.path };\n\t\t});\n\t} catch (error) {\n\t\tconsole.error(error);\n\t\treturn { error };\n\t}\n};\n\nexport const vectorSearch = async (collection: string, imageEmbedding: number[]) => {\n\tconst coll = db.collection(collection);\n\tconst vectorQuery: VectorQuery = await coll.findNearest(\n\t\t'embeddings',\n\t\tFieldValue.vector(imageEmbedding),\n\t\t{\n\t\t\tlimit: 30,\n\t\t\tdistanceMeasure: 'EUCLIDEAN' // other options COSINE and DOT_PRODUCT\n\t\t}\n\t);\n\tconst snapshot: VectorQuerySnapshot = await vectorQuery.get();\n\treturn snapshot.docs;\n};\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/CollectionsList.svelte",
    "content": "<script lang=\"ts\">\n\timport { onMount } from 'svelte';\n\timport { Button } from './ui/button';\n\timport { postJson } from '$lib/utils';\n\timport { useEmulator } from '$lib/consts';\n\timport type { Collection } from '$lib/api';\n\timport * as DropdownMenu from './ui/dropdown-menu';\n\n\texport let selectedCollection: string = 'Loading...';\n\texport let prefix: string = 'Collection';\n\n\tlet collections: Collection[];\n\n\tconst getCollections = async () => {\n\t\tconst res = await postJson('/api/listCollections', {});\n\t\tcollections = res.collections;\n\t};\n\n\t// We had built a large Firestore database with many collections of\n\t// embeddings we were testing with, so a simple dropdown component made\n\t// lots of sense, but setting it to a single collection (like when we're emulating)\n\t// works too!\n\tonMount(async () => {\n\t\tif (useEmulator) {\n\t\t\tselectedCollection = 'weather';\n\t\t} else {\n\t\t\tawait getCollections();\n\t\t\tif (collections.length) {\n\t\t\t\t// Grab first collection as default if available\n\t\t\t\tselectedCollection = collections[0].id;\n\t\t\t}\n\t\t}\n\t});\n</script>\n\n<div class=\"flex-c\"></div>\n\n<div class={$$props.class}>\n\t<DropdownMenu.Root>\n\t\t<DropdownMenu.Trigger asChild let:builder>\n\t\t\t<Button class=\"min-w-64\" builders={[builder]} variant=\"outline\"\n\t\t\t\t>{prefix}: <span class=\"ml-2 text-blue-300\">{selectedCollection}</span>\n\t\t\t</Button>\n\t\t</DropdownMenu.Trigger>\n\t\t<DropdownMenu.Content class=\"min-w-64\">\n\t\t\t{#if collections}\n\t\t\t\t<DropdownMenu.RadioGroup bind:value={selectedCollection}>\n\t\t\t\t\t{#each collections as collection}\n\t\t\t\t\t\t<DropdownMenu.RadioItem value={collection.id}>{collection.id}</DropdownMenu.RadioItem>\n\t\t\t\t\t{/each}\n\t\t\t\t</DropdownMenu.RadioGroup>\n\t\t\t{/if}\n\t\t</DropdownMenu.Content>\n\t</DropdownMenu.Root>\n</div>\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/Droppable.svelte",
    "content": "<script lang=\"ts\">\n\tlet dropDiv: HTMLDivElement;\n\n\texport let droppedFile: File;\n\n\tconst preventDrag = (event: DragEvent) => event.preventDefault();\n\n\tconst hideDroppable = () => {\n\t\tdropDiv.style.opacity = '0';\n\t};\n\n\tconst onDragEnter = (e: DragEvent) => {\n\t\te.preventDefault();\n\t\tdropDiv.style.opacity = '0.2';\n\t};\n\n\tconst onDragLeave = (e: DragEvent) => {\n\t\te.preventDefault();\n\t\thideDroppable();\n\t};\n\n\tconst onDrop = (e: DragEvent) => {\n\t\te.preventDefault();\n\t\thideDroppable();\n\n\t\tconst file = e.dataTransfer?.files[0];\n\n\t\t// Check if the file is an image\n\t\tif (file && file.type.match(/image\\/.*/)) {\n\t\t\tconsole.log(file);\n\t\t\tdroppedFile = file;\n\t\t}\n\t};\n</script>\n\n<svelte:document\n\ton:dragenter={onDragEnter}\n\ton:dragover={preventDrag}\n\ton:dragleave={onDragLeave}\n\ton:drop={onDrop}\n/>\n<!-- svelte-ignore a11y-no-static-element-interactions -->\n<div\n\tbind:this={dropDiv}\n\ton:dragenter={onDragEnter}\n\ton:dragover={onDragLeave}\n\ton:drop={onDrop}\n\tclass=\"pointer-events-none absolute top-0 h-svh w-full bg-red-900 opacity-0 transition-opacity\"\n></div>\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/button/button.svelte",
    "content": "<script lang=\"ts\">\n\timport { Button as ButtonPrimitive } from \"bits-ui\";\n\timport { type Events, type Props, buttonVariants } from \"./index.js\";\n\timport { cn } from \"$lib/utils.js\";\n\n\ttype $$Props = Props;\n\ttype $$Events = Events;\n\n\tlet className: $$Props[\"class\"] = undefined;\n\texport let variant: $$Props[\"variant\"] = \"default\";\n\texport let size: $$Props[\"size\"] = \"default\";\n\texport let builders: $$Props[\"builders\"] = [];\n\texport { className as class };\n</script>\n\n<ButtonPrimitive.Root\n\t{builders}\n\tclass={cn(buttonVariants({ variant, size, className }))}\n\ttype=\"button\"\n\t{...$$restProps}\n\ton:click\n\ton:keydown\n>\n\t<slot />\n</ButtonPrimitive.Root>\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/button/index.ts",
    "content": "import type { Button as ButtonPrimitive } from \"bits-ui\";\nimport { type VariantProps, tv } from \"tailwind-variants\";\nimport Root from \"./button.svelte\";\n\nconst buttonVariants = tv({\n\tbase: \"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\",\n\tvariants: {\n\t\tvariant: {\n\t\t\tdefault: \"bg-primary text-primary-foreground shadow hover:bg-primary/90\",\n\t\t\tdestructive:\n\t\t\t\t\"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90\",\n\t\t\toutline:\n\t\t\t\t\"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground\",\n\t\t\tsecondary: \"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80\",\n\t\t\tghost: \"hover:bg-accent hover:text-accent-foreground\",\n\t\t\tlink: \"text-primary underline-offset-4 hover:underline\",\n\t\t},\n\t\tsize: {\n\t\t\tdefault: \"h-9 px-4 py-2\",\n\t\t\tsm: \"h-8 rounded-md px-3 text-xs\",\n\t\t\tlg: \"h-10 rounded-md px-8\",\n\t\t\ticon: \"h-9 w-9\",\n\t\t},\n\t},\n\tdefaultVariants: {\n\t\tvariant: \"default\",\n\t\tsize: \"default\",\n\t},\n});\n\ntype Variant = VariantProps<typeof buttonVariants>[\"variant\"];\ntype Size = VariantProps<typeof buttonVariants>[\"size\"];\n\ntype Props = ButtonPrimitive.Props & {\n\tvariant?: Variant;\n\tsize?: Size;\n};\n\ntype Events = ButtonPrimitive.Events;\n\nexport {\n\tRoot,\n\ttype Props,\n\ttype Events,\n\t//\n\tRoot as Button,\n\ttype Props as ButtonProps,\n\ttype Events as ButtonEvents,\n\tbuttonVariants,\n};\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte",
    "content": "<script lang=\"ts\">\n\timport { DropdownMenu as DropdownMenuPrimitive } from \"bits-ui\";\n\timport Check from \"svelte-radix/Check.svelte\";\n\timport { cn } from \"$lib/utils.js\";\n\n\ttype $$Props = DropdownMenuPrimitive.CheckboxItemProps;\n\ttype $$Events = DropdownMenuPrimitive.CheckboxItemEvents;\n\n\tlet className: $$Props[\"class\"] = undefined;\n\texport let checked: $$Props[\"checked\"] = undefined;\n\texport { className as class };\n</script>\n\n<DropdownMenuPrimitive.CheckboxItem\n\tbind:checked\n\tclass={cn(\n\t\t\"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50\",\n\t\tclassName\n\t)}\n\t{...$$restProps}\n\ton:click\n\ton:keydown\n\ton:focusin\n\ton:focusout\n\ton:pointerdown\n\ton:pointerleave\n\ton:pointermove\n>\n\t<span class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n\t\t<DropdownMenuPrimitive.CheckboxIndicator>\n\t\t\t<Check class=\"h-4 w-4\" />\n\t\t</DropdownMenuPrimitive.CheckboxIndicator>\n\t</span>\n\t<slot />\n</DropdownMenuPrimitive.CheckboxItem>\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte",
    "content": "<script lang=\"ts\">\n\timport { DropdownMenu as DropdownMenuPrimitive } from \"bits-ui\";\n\timport { cn, flyAndScale } from \"$lib/utils.js\";\n\n\ttype $$Props = DropdownMenuPrimitive.ContentProps;\n\n\tlet className: $$Props[\"class\"] = undefined;\n\texport let sideOffset: $$Props[\"sideOffset\"] = 4;\n\texport let transition: $$Props[\"transition\"] = flyAndScale;\n\texport let transitionConfig: $$Props[\"transitionConfig\"] = undefined;\n\texport { className as class };\n</script>\n\n<DropdownMenuPrimitive.Content\n\t{transition}\n\t{transitionConfig}\n\t{sideOffset}\n\tclass={cn(\n\t\t\"z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md focus:outline-none\",\n\t\tclassName\n\t)}\n\t{...$$restProps}\n\ton:keydown\n>\n\t<slot />\n</DropdownMenuPrimitive.Content>\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte",
    "content": "<script lang=\"ts\">\n\timport { DropdownMenu as DropdownMenuPrimitive } from \"bits-ui\";\n\timport { cn } from \"$lib/utils.js\";\n\n\ttype $$Props = DropdownMenuPrimitive.ItemProps & {\n\t\tinset?: boolean;\n\t};\n\ttype $$Events = DropdownMenuPrimitive.ItemEvents;\n\n\tlet className: $$Props[\"class\"] = undefined;\n\texport let inset: $$Props[\"inset\"] = undefined;\n\texport { className as class };\n</script>\n\n<DropdownMenuPrimitive.Item\n\tclass={cn(\n\t\t\"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50\",\n\t\tinset && \"pl-8\",\n\t\tclassName\n\t)}\n\ton:click\n\ton:keydown\n\ton:focusin\n\ton:focusout\n\ton:pointerdown\n\ton:pointerleave\n\ton:pointermove\n\t{...$$restProps}\n>\n\t<slot />\n</DropdownMenuPrimitive.Item>\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte",
    "content": "<script lang=\"ts\">\n\timport { DropdownMenu as DropdownMenuPrimitive } from \"bits-ui\";\n\timport { cn } from \"$lib/utils.js\";\n\n\ttype $$Props = DropdownMenuPrimitive.LabelProps & {\n\t\tinset?: boolean;\n\t};\n\n\tlet className: $$Props[\"class\"] = undefined;\n\texport let inset: $$Props[\"inset\"] = undefined;\n\texport { className as class };\n</script>\n\n<DropdownMenuPrimitive.Label\n\tclass={cn(\"px-2 py-1.5 text-sm font-semibold\", inset && \"pl-8\", className)}\n\t{...$$restProps}\n>\n\t<slot />\n</DropdownMenuPrimitive.Label>\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte",
    "content": "<script lang=\"ts\">\n\timport { DropdownMenu as DropdownMenuPrimitive } from \"bits-ui\";\n\n\ttype $$Props = DropdownMenuPrimitive.RadioGroupProps;\n\n\texport let value: $$Props[\"value\"] = undefined;\n</script>\n\n<DropdownMenuPrimitive.RadioGroup {...$$restProps} bind:value>\n\t<slot />\n</DropdownMenuPrimitive.RadioGroup>\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte",
    "content": "<script lang=\"ts\">\n\timport { DropdownMenu as DropdownMenuPrimitive } from \"bits-ui\";\n\timport DotFilled from \"svelte-radix/DotFilled.svelte\";\n\timport { cn } from \"$lib/utils.js\";\n\n\ttype $$Props = DropdownMenuPrimitive.RadioItemProps;\n\ttype $$Events = DropdownMenuPrimitive.RadioItemEvents;\n\n\tlet className: $$Props[\"class\"] = undefined;\n\texport let value: DropdownMenuPrimitive.RadioItemProps[\"value\"];\n\texport { className as class };\n</script>\n\n<DropdownMenuPrimitive.RadioItem\n\tclass={cn(\n\t\t\"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50\",\n\t\tclassName\n\t)}\n\t{value}\n\t{...$$restProps}\n\ton:click\n\ton:keydown\n\ton:focusin\n\ton:focusout\n\ton:pointerdown\n\ton:pointerleave\n\ton:pointermove\n>\n\t<span class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n\t\t<DropdownMenuPrimitive.RadioIndicator>\n\t\t\t<DotFilled class=\"h-4 w-4 fill-current\" />\n\t\t</DropdownMenuPrimitive.RadioIndicator>\n\t</span>\n\t<slot />\n</DropdownMenuPrimitive.RadioItem>\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte",
    "content": "<script lang=\"ts\">\n\timport { DropdownMenu as DropdownMenuPrimitive } from \"bits-ui\";\n\timport { cn } from \"$lib/utils.js\";\n\n\ttype $$Props = DropdownMenuPrimitive.SeparatorProps;\n\n\tlet className: $$Props[\"class\"] = undefined;\n\texport { className as class };\n</script>\n\n<DropdownMenuPrimitive.Separator\n\tclass={cn(\"-mx-1 my-1 h-px bg-muted\", className)}\n\t{...$$restProps}\n/>\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte",
    "content": "<script lang=\"ts\">\n\timport type { HTMLAttributes } from \"svelte/elements\";\n\timport { cn } from \"$lib/utils.js\";\n\n\ttype $$Props = HTMLAttributes<HTMLSpanElement>;\n\n\tlet className: $$Props[\"class\"] = undefined;\n\texport { className as class };\n</script>\n\n<span class={cn(\"ml-auto text-xs tracking-widest opacity-60\", className)} {...$$restProps}>\n\t<slot />\n</span>\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte",
    "content": "<script lang=\"ts\">\n\timport { DropdownMenu as DropdownMenuPrimitive } from \"bits-ui\";\n\timport { cn, flyAndScale } from \"$lib/utils.js\";\n\n\ttype $$Props = DropdownMenuPrimitive.SubContentProps;\n\n\tlet className: $$Props[\"class\"] = undefined;\n\texport let transition: $$Props[\"transition\"] = flyAndScale;\n\texport let transitionConfig: $$Props[\"transitionConfig\"] = {\n\t\tx: -10,\n\t\ty: 0,\n\t};\n\texport { className as class };\n</script>\n\n<DropdownMenuPrimitive.SubContent\n\t{transition}\n\t{transitionConfig}\n\tclass={cn(\n\t\t\"z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-lg focus:outline-none\",\n\t\tclassName\n\t)}\n\t{...$$restProps}\n\ton:keydown\n\ton:focusout\n\ton:pointermove\n>\n\t<slot />\n</DropdownMenuPrimitive.SubContent>\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte",
    "content": "<script lang=\"ts\">\n\timport { DropdownMenu as DropdownMenuPrimitive } from \"bits-ui\";\n\timport ChevronRight from \"svelte-radix/ChevronRight.svelte\";\n\timport { cn } from \"$lib/utils.js\";\n\n\ttype $$Props = DropdownMenuPrimitive.SubTriggerProps & {\n\t\tinset?: boolean;\n\t};\n\ttype $$Events = DropdownMenuPrimitive.SubTriggerEvents;\n\n\tlet className: $$Props[\"class\"] = undefined;\n\texport let inset: $$Props[\"inset\"] = undefined;\n\texport { className as class };\n</script>\n\n<DropdownMenuPrimitive.SubTrigger\n\tclass={cn(\n\t\t\"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground\",\n\t\tinset && \"pl-8\",\n\t\tclassName\n\t)}\n\t{...$$restProps}\n\ton:click\n\ton:keydown\n\ton:focusin\n\ton:focusout\n\ton:pointerleave\n\ton:pointermove\n>\n\t<slot />\n\t<ChevronRight class=\"ml-auto h-4 w-4\" />\n</DropdownMenuPrimitive.SubTrigger>\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/dropdown-menu/index.ts",
    "content": "import { DropdownMenu as DropdownMenuPrimitive } from \"bits-ui\";\nimport Item from \"./dropdown-menu-item.svelte\";\nimport Label from \"./dropdown-menu-label.svelte\";\nimport Content from \"./dropdown-menu-content.svelte\";\nimport Shortcut from \"./dropdown-menu-shortcut.svelte\";\nimport RadioItem from \"./dropdown-menu-radio-item.svelte\";\nimport Separator from \"./dropdown-menu-separator.svelte\";\nimport RadioGroup from \"./dropdown-menu-radio-group.svelte\";\nimport SubContent from \"./dropdown-menu-sub-content.svelte\";\nimport SubTrigger from \"./dropdown-menu-sub-trigger.svelte\";\nimport CheckboxItem from \"./dropdown-menu-checkbox-item.svelte\";\n\nconst Sub = DropdownMenuPrimitive.Sub;\nconst Root = DropdownMenuPrimitive.Root;\nconst Trigger = DropdownMenuPrimitive.Trigger;\nconst Group = DropdownMenuPrimitive.Group;\n\nexport {\n\tSub,\n\tRoot,\n\tItem,\n\tLabel,\n\tGroup,\n\tTrigger,\n\tContent,\n\tShortcut,\n\tSeparator,\n\tRadioItem,\n\tSubContent,\n\tSubTrigger,\n\tRadioGroup,\n\tCheckboxItem,\n\t//\n\tRoot as DropdownMenu,\n\tSub as DropdownMenuSub,\n\tItem as DropdownMenuItem,\n\tLabel as DropdownMenuLabel,\n\tGroup as DropdownMenuGroup,\n\tContent as DropdownMenuContent,\n\tTrigger as DropdownMenuTrigger,\n\tShortcut as DropdownMenuShortcut,\n\tRadioItem as DropdownMenuRadioItem,\n\tSeparator as DropdownMenuSeparator,\n\tRadioGroup as DropdownMenuRadioGroup,\n\tSubContent as DropdownMenuSubContent,\n\tSubTrigger as DropdownMenuSubTrigger,\n\tCheckboxItem as DropdownMenuCheckboxItem,\n};\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/input/index.ts",
    "content": "import Root from \"./input.svelte\";\n\nexport type FormInputEvent<T extends Event = Event> = T & {\n\tcurrentTarget: EventTarget & HTMLInputElement;\n};\nexport type InputEvents = {\n\tblur: FormInputEvent<FocusEvent>;\n\tchange: FormInputEvent<Event>;\n\tclick: FormInputEvent<MouseEvent>;\n\tfocus: FormInputEvent<FocusEvent>;\n\tfocusin: FormInputEvent<FocusEvent>;\n\tfocusout: FormInputEvent<FocusEvent>;\n\tkeydown: FormInputEvent<KeyboardEvent>;\n\tkeypress: FormInputEvent<KeyboardEvent>;\n\tkeyup: FormInputEvent<KeyboardEvent>;\n\tmouseover: FormInputEvent<MouseEvent>;\n\tmouseenter: FormInputEvent<MouseEvent>;\n\tmouseleave: FormInputEvent<MouseEvent>;\n\tmousemove: FormInputEvent<MouseEvent>;\n\tpaste: FormInputEvent<ClipboardEvent>;\n\tinput: FormInputEvent<InputEvent>;\n\twheel: FormInputEvent<WheelEvent>;\n};\n\nexport {\n\tRoot,\n\t//\n\tRoot as Input,\n};\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/input/input.svelte",
    "content": "<script lang=\"ts\">\n\timport type { HTMLInputAttributes } from \"svelte/elements\";\n\timport type { InputEvents } from \"./index.js\";\n\timport { cn } from \"$lib/utils.js\";\n\n\ttype $$Props = HTMLInputAttributes;\n\ttype $$Events = InputEvents;\n\n\tlet className: $$Props[\"class\"] = undefined;\n\texport let value: $$Props[\"value\"] = undefined;\n\texport { className as class };\n\n\t// Workaround for https://github.com/sveltejs/svelte/issues/9305\n\t// Fixed in Svelte 5, but not backported to 4.x.\n\texport let readonly: $$Props[\"readonly\"] = undefined;\n</script>\n\n<input\n\tclass={cn(\n\t\t\"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\",\n\t\tclassName\n\t)}\n\tbind:value\n\t{readonly}\n\ton:blur\n\ton:change\n\ton:click\n\ton:focus\n\ton:focusin\n\ton:focusout\n\ton:keydown\n\ton:keypress\n\ton:keyup\n\ton:mouseover\n\ton:mouseenter\n\ton:mouseleave\n\ton:mousemove\n\ton:paste\n\ton:input\n\ton:wheel|passive\n\t{...$$restProps}\n/>\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/label/index.ts",
    "content": "import Root from \"./label.svelte\";\n\nexport {\n\tRoot,\n\t//\n\tRoot as Label,\n};\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/components/ui/label/label.svelte",
    "content": "<script lang=\"ts\">\n\timport { Label as LabelPrimitive } from \"bits-ui\";\n\timport { cn } from \"$lib/utils.js\";\n\n\ttype $$Props = LabelPrimitive.Props;\n\n\tlet className: $$Props[\"class\"] = undefined;\n\texport { className as class };\n</script>\n\n<LabelPrimitive.Root\n\tclass={cn(\n\t\t\"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\",\n\t\tclassName\n\t)}\n\t{...$$restProps}\n>\n\t<slot />\n</LabelPrimitive.Root>\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/consts.ts",
    "content": "// We can set some reasonable defaults for testing with exported data\n// using the Firebase emulators. Follow along in the README for more info.\nexport const useEmulator = import.meta.env.VITE_USE_EMULATOR;\n\n// due to our emulator data being a direct, binary export of our testing databases\n// the name here reflects the way for the emulator to properly ingest it for testing\n// DONT FORGET - if using the emulator UI - when viewing Firestore, replace 'default'\n// with 'cl-demos-firestore' in the URL so you can see the imported test data\nexport const FIRESTORE_DB_ID = useEmulator ? 'cl-demos-firestore' : 'your-project-firestore-db';\nexport const FIREBASE_STORAGE_EMULATOR_BUCKET = 'mm-demo.appspot.com'; // same as above, for emulator\nexport const DEFAULT_EMULATOR_FIRESTORE_COLLECTION = 'weather';\nexport const FIREBASE_PROJECT_ID = 'your-project-id'; // this should match the auto generated firebase.rc after running `firebase init`\nexport const FIREBASE_CONFIG = {\n\tprojectId: FIREBASE_PROJECT_ID,\n\tstorageBucket: useEmulator\n\t\t? FIREBASE_STORAGE_EMULATOR_BUCKET\n\t\t: `${FIREBASE_PROJECT_ID}.appspot.com`\n\t// while the above might work for initial testing, follow the link below for proper values\n\t// rest of your config, from the firebase console\n\t// directions: https://firebase.google.com/docs/web/setup\n};\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/embedder.ts",
    "content": "/**\n * See https://cloud.google.com/vertex-ai/generative-ai/docs/embeddings/get-multimodal-embedding\n * for more info on how this is built.\n */\nimport * as aiplatform from '@google-cloud/aiplatform';\nimport type { EmbeddingRequest } from './api';\nimport { FIREBASE_PROJECT_ID } from './consts';\n\nconst { PredictionServiceClient } = aiplatform.v1;\nconst { helpers } = aiplatform;\n\nconst clientOptions = {\n\tapiEndpoint: 'us-central1-aiplatform.googleapis.com'\n};\nconst project = FIREBASE_PROJECT_ID; // your firebase/cloud project\nconst location = 'us-central1';\nconst publisher = 'google';\nconst model = 'multimodalembedding@001';\nconst predictionServiceClient = new PredictionServiceClient(clientOptions);\nconst endpoint = `projects/${project}/locations/${location}/publishers/${publisher}/models/${model}`;\n\ntype Prompt = {\n\timage?: {\n\t\tbytesBase64Encoded: string;\n\t};\n\ttext?: string;\n};\n\nexport const getEmbeddings = async (embedRequest: EmbeddingRequest) => {\n\t// all params:\n\t// https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/multimodal-embeddings-api#parameter-list\n\n\tlet prompt: Prompt = {};\n\n\tif (embedRequest.imageBytes) {\n\t\tlet b64 = '';\n\t\tif (typeof embedRequest.imageBytes === 'string') {\n\t\t\tb64 = embedRequest.imageBytes;\n\t\t} else {\n\t\t\tb64 = embedRequest.imageBytes.toString('base64');\n\t\t}\n\t\tprompt.image = {\n\t\t\tbytesBase64Encoded: b64\n\t\t};\n\t}\n\tif (embedRequest.text) {\n\t\tprompt.text = embedRequest.text;\n\t}\n\n\tconsole.log('prompt:', prompt);\n\n\tconst instanceValue = helpers.toValue(prompt);\n\tconst instances = [instanceValue];\n\n\tconst parameter = {\n\t\tsampleCount: 1\n\t};\n\tconst parameters = helpers.toValue(parameter);\n\n\tconst request = {\n\t\tendpoint,\n\t\tinstances,\n\t\tparameters\n\t};\n\n\t// Predict request\n\tconst [response] = await predictionServiceClient.predict(request);\n\tconst predictions = response.predictions || [];\n\tif (predictions && predictions.length > 0) {\n\t\tconst availableFields = predictions[0].structValue.fields;\n\n\t\tlet embedResponse = {};\n\n\t\tif (availableFields.imageEmbedding) {\n\t\t\tembedResponse.imageEmbeddings = availableFields.imageEmbedding.listValue.values.map(\n\t\t\t\t(vals) => vals.numberValue\n\t\t\t);\n\t\t}\n\n\t\tif (availableFields.textEmbedding) {\n\t\t\tembedResponse.textEmbeddings = availableFields.textEmbedding.listValue.values.map(\n\t\t\t\t(vals) => vals.numberValue\n\t\t\t);\n\t\t}\n\t\tconsole.log('Response generated:', embedResponse);\n\t\treturn embedResponse;\n\t} else {\n\t\tthrow Error('Failure to create embeddings');\n\t}\n};\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/firebase.ts",
    "content": "import { initializeApp } from 'firebase/app';\nimport { collection, connectFirestoreEmulator, getDocs, getFirestore } from 'firebase/firestore';\nimport { getStorage, ref, getDownloadURL, connectStorageEmulator } from 'firebase/storage';\nimport {\n\tFIREBASE_CONFIG,\n\tFIREBASE_STORAGE_EMULATOR_BUCKET,\n\tFIRESTORE_DB_ID,\n\tuseEmulator\n} from './consts';\n\nexport const app = initializeApp(FIREBASE_CONFIG);\nexport const fs = getFirestore(FIRESTORE_DB_ID);\n\n// The emulator export comes with its own storage bucket name, used here\nexport let storage = useEmulator\n\t? getStorage(app, FIREBASE_STORAGE_EMULATOR_BUCKET)\n\t: getStorage(app);\n\nif (useEmulator) {\n\tconnectFirestoreEmulator(fs, 'localhost', 8080);\n\tconnectStorageEmulator(storage, 'localhost', 9199);\n}\n\nexport const allDocsInCollection = async (collName: string) => {\n\treturn await getDocs(collection(fs, collName));\n};\n\nexport const getThumb = async (thumbPath: string) => {\n\treturn await getDownloadURL(ref(storage, thumbPath));\n};\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/store.ts",
    "content": "import { writable } from 'svelte/store';\nimport type { UMAPParameters } from 'umap-js';\nimport type { DocumentData } from 'firebase/firestore';\n\nexport type ImageData = {\n\tcollection: string;\n\tdoc: DocumentData;\n\tumap?: number[];\n\tlabel?: string;\n\tref?: any;\n};\n\nexport type ImageCollection = {\n\tname: string;\n\timages: ImageData[];\n};\n\n// component access hack\ntype RemapFunction = () => Promise<void>;\nexport const remapFn = writable<RemapFunction>();\n\nexport const collections = writable<ImageCollection[]>([]);\nexport const allImages = writable<ImageData[]>([]);\n\n// Settings\n\n// UMAP bindings\n\nexport const umapParams = writable<UMAPParameters>({\n\tnComponents: 3,\n\tnNeighbors: 15,\n\tnEpochs: 400,\n\tminDist: 0.5,\n\tspread: 1.5\n});\n"
  },
  {
    "path": "multimodal-embeddings/src/lib/utils.ts",
    "content": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\nimport { cubicOut } from 'svelte/easing';\nimport type { TransitionConfig } from 'svelte/transition';\n\nexport function cn(...inputs: ClassValue[]) {\n\treturn twMerge(clsx(inputs));\n}\n\ntype FlyAndScaleParams = {\n\ty?: number;\n\tx?: number;\n\tstart?: number;\n\tduration?: number;\n};\n\nexport const flyAndScale = (\n\tnode: Element,\n\tparams: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }\n): TransitionConfig => {\n\tconst style = getComputedStyle(node);\n\tconst transform = style.transform === 'none' ? '' : style.transform;\n\n\tconst scaleConversion = (valueA: number, scaleA: [number, number], scaleB: [number, number]) => {\n\t\tconst [minA, maxA] = scaleA;\n\t\tconst [minB, maxB] = scaleB;\n\n\t\tconst percentage = (valueA - minA) / (maxA - minA);\n\t\tconst valueB = percentage * (maxB - minB) + minB;\n\n\t\treturn valueB;\n\t};\n\n\tconst styleToString = (style: Record<string, number | string | undefined>): string => {\n\t\treturn Object.keys(style).reduce((str, key) => {\n\t\t\tif (style[key] === undefined) return str;\n\t\t\treturn str + `${key}:${style[key]};`;\n\t\t}, '');\n\t};\n\n\treturn {\n\t\tduration: params.duration ?? 200,\n\t\tdelay: 0,\n\t\tcss: (t) => {\n\t\t\tconst y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]);\n\t\t\tconst x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]);\n\t\t\tconst scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]);\n\n\t\t\treturn styleToString({\n\t\t\t\ttransform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`,\n\t\t\t\topacity: t\n\t\t\t});\n\t\t},\n\t\teasing: cubicOut\n\t};\n};\n\nexport const postJson = async (url: string, data: any) => {\n\tconsole.log(`Call ${url}`, data);\n\tconst response = await fetch(url, {\n\t\tmethod: 'POST',\n\t\theaders: {\n\t\t\t'Content-Type': 'application/json'\n\t\t},\n\t\tbody: JSON.stringify(data)\n\t});\n\n\tconst json = await response.json();\n\n\tif (json.error) throw json.error;\n\n\treturn json;\n};\n"
  },
  {
    "path": "multimodal-embeddings/src/routes/+layout.svelte",
    "content": "<slot></slot><script>import \"../app.css\";</script><style></style>"
  },
  {
    "path": "multimodal-embeddings/src/routes/+page.svelte",
    "content": "<p>Nope.</p>\n"
  },
  {
    "path": "multimodal-embeddings/src/routes/api/getEmbeddings/+server.ts",
    "content": "import { json } from '@sveltejs/kit';\nimport { getEmbeddings } from '$lib/embedder';\nimport type { EmbeddingRequest } from '$lib/api.js';\n\n/**\n * Search API method using image embeddings for use in the /search route\n * @param request\n * @returns the embeddings and the results of the vector search\n */\nexport async function POST({ request }) {\n\ttry {\n\t\tconst embedRequest = (await request.json()) as EmbeddingRequest;\n\t\tconsole.log('/api/getEmbeddings:', embedRequest);\n\n\t\tconst embeddings = await getEmbeddings(embedRequest);\n\t\t// console.log('embedResult:', embeddings);\n\n\t\treturn json({ embeddings });\n\t} catch (error) {\n\t\tconsole.error(error);\n\t\treturn json({ error });\n\t}\n}\n"
  },
  {
    "path": "multimodal-embeddings/src/routes/api/listCollections/+server.ts",
    "content": "import { json } from '@sveltejs/kit';\nimport { listCollections } from '$lib/cloud-firebase';\n\n/**\n * Search API method using image embeddings for use in the /search route\n * @param request\n * @returns the embeddings and the results of the vector search\n */\nexport async function POST() {\n\ttry {\n\t\tconst collections = await listCollections();\n\t\tconsole.log('/api/listCollections:', collections);\n\n\t\treturn json({ collections });\n\t} catch (error) {\n\t\tconsole.error(error);\n\t\treturn json({ error });\n\t}\n}\n"
  },
  {
    "path": "multimodal-embeddings/src/routes/api/search/+server.ts",
    "content": "import { json } from '@sveltejs/kit';\nimport { vectorSearch } from '$lib/cloud-firebase';\n\n/**\n * Search API method using image embeddings for use in the /search route\n * @param request\n * @returns the embeddings and the results of the vector search\n */\nexport async function POST({ request }) {\n\ttry {\n\t\tconst body = await request.json();\n\n\t\tconsole.log('/api/search:', body);\n\n\t\tconst searchResultDocs = await vectorSearch(body.collection, body.embeddings);\n\t\tconst searchResults = searchResultDocs.map((doc) => doc.data());\n\n\t\treturn json({ searchResults });\n\t} catch (error) {\n\t\tconsole.error(error);\n\t\treturn json({ error });\n\t}\n}\n"
  },
  {
    "path": "multimodal-embeddings/src/routes/api/umap/+server.ts",
    "content": "import { UMAP, type UMAPParameters } from 'umap-js';\nimport { json } from '@sveltejs/kit';\n\nexport const POST = async ({ request }) => {\n\ttry {\n\t\tconst data = await request.json();\n\n\t\tconst modelOpts: UMAPParameters = {\n\t\t\tnComponents: data.dimensions,\n\t\t\tnEpochs: data.epochs,\n\t\t\tnNeighbors: data.neighbors,\n\t\t\tminDist: data.minDist,\n\t\t\tspread: data.spread\n\t\t};\n\t\tconsole.debug(modelOpts);\n\n\t\tconst model = new UMAP(modelOpts);\n\t\tconst mapped = await model.fitAsync(data.embeddings);\n\t\tconsole.log(mapped[0]);\n\n\t\treturn json({ data: mapped });\n\t} catch (err) {\n\t\tconsole.error(err);\n\t\treturn json({ err });\n\t}\n};\n"
  },
  {
    "path": "multimodal-embeddings/src/routes/search/+page.svelte",
    "content": "<script lang=\"ts\">\n\timport { writable } from 'svelte/store';\n\timport { Button } from '$lib/components/ui/button';\n\timport { postJson } from '$lib/utils';\n\timport { Input } from '$lib/components/ui/input';\n\timport { Label } from '$lib/components/ui/label';\n\timport type { EmbeddingRequest, SearchRequest, SearchResponse } from '$lib/api';\n\timport CollectionsList from '$lib/components/CollectionsList.svelte';\n\timport Droppable from '$lib/components/Droppable.svelte';\n\timport ImageResult from './ImageResult.svelte';\n\timport LoaderCircle from 'lucide-svelte/icons/loader-circle';\n\n\tconst enum SearchState {\n\t\tWaiting = 'Waiting',\n\t\tEmbeddingRequest = 'Embedding request...',\n\t\tSearching = 'Searching...'\n\t}\n\tlet searchState = writable<SearchState>(SearchState.Waiting);\n\n\tlet inputEl: HTMLInputElement;\n\tlet file: File;\n\t$: if (file) {\n\t\tonFileUpdate();\n\t}\n\n\tlet selectedCollection: string;\n\tlet dataURL: string = '';\n\tlet searchText: string = '';\n\tlet searchResponse: SearchResponse | null;\n\tlet errorResponse: string = '';\n\n\tconst onFilesChange = (e: any) => {\n\t\tconsole.log(e.target.files);\n\t\tfile = e.target.files[0];\n\t};\n\n\tconst onFileUpdate = () => {\n\t\tconst reader = new FileReader();\n\t\treader.onload = () => {\n\t\t\tdataURL = reader.result as string;\n\t\t};\n\t\treader.readAsDataURL(file);\n\t};\n\n\tconst search = async () => {\n\t\tconsole.log(searchText, file);\n\t\ttry {\n\t\t\terrorResponse = '';\n\t\t\tsearchResponse = null;\n\n\t\t\tlet embedRequest: EmbeddingRequest = {};\n\n\t\t\tif (dataURL) {\n\t\t\t\tembedRequest.imageBytes = dataURL.split(',').pop();\n\t\t\t} // else if <---- this might be necessary if API is ONLY EITHER OR\n\t\t\tif (searchText) {\n\t\t\t\tembedRequest.text = searchText;\n\t\t\t}\n\t\t\t// first we need to embed the image/text into vector space\n\t\t\tsearchState.set(SearchState.EmbeddingRequest);\n\t\t\tconst embedResponse = await postJson('/api/getEmbeddings', embedRequest);\n\t\t\tconsole.log(embedResponse);\n\n\t\t\t//then we can search against a collection's vector index with that embedding\n\t\t\tsearchState.set(SearchState.Searching);\n\t\t\tsearchResponse = await postJson('/api/search', {\n\t\t\t\tembeddings: searchText\n\t\t\t\t\t? embedResponse.embeddings.textEmbeddings\n\t\t\t\t\t: embedResponse.embeddings.imageEmbeddings,\n\t\t\t\tcollection: selectedCollection\n\t\t\t} as SearchRequest);\n\t\t\tconsole.log(searchResponse);\n\t\t} catch (err: any) {\n\t\t\tconsole.error('Search Error', err);\n\t\t\terrorResponse = err.details ? err.details.toString() : err;\n\t\t} finally {\n\t\t\tsearchState.set(SearchState.Waiting);\n\t\t}\n\t};\n\n\tconst onSearchTextKeyDown = (e: KeyboardEvent) => {\n\t\tif (e.key == 'Enter') {\n\t\t\tsearch();\n\t\t}\n\t};\n</script>\n\n<div class=\"m-auto max-w-5xl p-4\">\n\t<div class=\"flex w-full flex-row\">\n\t\t<div class=\"mr-8 flex max-w-96 flex-col\">\n\t\t\t<h2 class=\"mb-8 text-3xl\">My Personal Search</h2>\n\n\t\t\t<Label for=\"searchText\" class=\"mb-2\">Search within:</Label>\n\t\t\t<CollectionsList class=\"mb-4\" bind:selectedCollection />\n\n\t\t\t<Label for=\"searchText\" class=\"mb-2\">What are you looking for?</Label>\n\t\t\t<Input\n\t\t\t\tid=\"searchText\"\n\t\t\t\tclass=\"mb-4\"\n\t\t\t\tbind:value={searchText}\n\t\t\t\tplaceholder={'\"fuzzy images\"'}\n\t\t\t\ton:keydown={onSearchTextKeyDown}\n\t\t\t/>\n\n\t\t\t<input type=\"file\" bind:this={inputEl} hidden accept=\"image/*\" on:change={onFilesChange} />\n\t\t\t<Label for=\"searchText\" class=\"mb-2\">Or Search by Image:</Label>\n\t\t\t<Button variant=\"outline\" class=\"mb-4\" on:click={() => inputEl.click()}\n\t\t\t\t>Choose image (or drag and drop)\n\t\t\t</Button>\n\n\t\t\t{#if file}\n\t\t\t\t<div class=\"mb-4\">\n\t\t\t\t\t<img src={dataURL} class=\"mx-auto my-0 max-h-48\" alt=\"Searchable input\" />\n\t\t\t\t</div>\n\t\t\t{/if}\n\n\t\t\t<Button on:click={search} disabled={dataURL.length == 0 && searchText.length == 0}\n\t\t\t\t>Search</Button\n\t\t\t>\n\n\t\t\t{#if $searchState != SearchState.Waiting}\n\t\t\t\t<div class=\"mt-8 flex flex-row items-center\">\n\t\t\t\t\t<LoaderCircle class=\"mr-2 animate-spin\" />\n\t\t\t\t\t<p class=\"text-sm\">{$searchState}</p>\n\t\t\t\t</div>\n\t\t\t{/if}\n\t\t\t{#if errorResponse.length > 0}\n\t\t\t\t<div class=\"mt-8 w-full border-2 border-solid border-red-500 p-4 text-center\">\n\t\t\t\t\t<p class=\"text-lg text-red-500\">\n\t\t\t\t\t\t{errorResponse}\n\t\t\t\t\t</p>\n\t\t\t\t</div>\n\t\t\t{/if}\n\t\t</div>\n\t\t<div class=\"w-full\">\n\t\t\t{#if searchResponse}\n\t\t\t\t<section class=\"grid grid-cols-2 gap-4\">\n\t\t\t\t\t{#each searchResponse.searchResults as result}\n\t\t\t\t\t\t<ImageResult class=\"mb-4 rounded-lg bg-gray-900 p-4\" {result} />\n\t\t\t\t\t{/each}\n\t\t\t\t</section>\n\t\t\t{/if}\n\t\t</div>\n\t</div>\n</div>\n<Droppable bind:droppedFile={file} />\n"
  },
  {
    "path": "multimodal-embeddings/src/routes/search/ImageResult.svelte",
    "content": "<script lang=\"ts\">\n\timport type { StoredImage } from '$lib/api';\n\timport { getThumb } from '$lib/firebase';\n\timport { onMount } from 'svelte';\n\n\texport let result: StoredImage;\n\tlet img: HTMLImageElement;\n\n\tonMount(async () => {\n\t\timg.src = await getThumb(result.filePath);\n\t});\n</script>\n\n<div class=\"flex {$$props.class}\">\n\t<img bind:this={img} class=\"\" alt=\"Thumbnail\" />\n</div>\n"
  },
  {
    "path": "multimodal-embeddings/src/routes/viz/+page.svelte",
    "content": "<script lang=\"ts\">\n\timport { onMount } from 'svelte';\n\timport { page } from '$app/stores';\n\timport { allDocsInCollection } from '$lib/firebase';\n\timport { collections, type ImageCollection, type ImageData } from '$lib/store';\n\timport CollectionsList from '$lib/components/CollectionsList.svelte';\n\timport Viz from './Viz.svelte';\n\timport Settings from './Settings.svelte';\n\n\tconst COLLECTION_PARAM = 'collection';\n\tconst DEFAULT_COLLECTION = 'weather'; // default is our public weather dataset\n\n\t// Originally only a visualizer of a single dataset, we started working towards comparing sets\n\t// but it didn't make it in this repo. Now it's just an easy way to view two collections at once.\n\n\tlet collection1: ImageCollection;\n\tlet collection2: ImageCollection;\n\tlet collection1name = '';\n\tlet collection2name = '';\n\t$: if (collection1name != '' && collection2name != '') {\n\t\tconsole.log('Collections Updated:', collection1name, collection2name);\n\t\tgetAllEmbeddings();\n\t}\n\n\tconst getAllEmbeddings = async () => {\n\t\tcollection1 = await getCollectionData(collection1name);\n\t\tcollection2 = await getCollectionData(collection2name);\n\n\t\tcollections.set([collection1, collection2]);\n\t};\n\n\tconst getCollectionData = async (collectionName: string) => {\n\t\ttry {\n\t\t\tconsole.log('getCollectionData()', collectionName);\n\t\t\t//  can throw error if collection doesnt exist\n\t\t\tconst allDocsSnap = await allDocsInCollection(collectionName);\n\t\t\tconst images: ImageData[] = [];\n\t\t\tallDocsSnap.forEach((doc) => {\n\t\t\t\tconst imageData: ImageData = {\n\t\t\t\t\tdoc: doc.data(),\n\t\t\t\t\tcollection: collectionName\n\t\t\t\t};\n\t\t\t\timages.push(imageData);\n\t\t\t});\n\n\t\t\tconst collection: ImageCollection = { name: collectionName, images };\n\t\t\treturn collection;\n\t\t} catch (err) {\n\t\t\t// kinda big deal but we can ignore for now\n\t\t\tconsole.error(err);\n\t\t\treturn;\n\t\t}\n\t};\n\n\tonMount(async () => {\n\t\tconst collection = $page.url.searchParams.get(COLLECTION_PARAM);\n\t\tif (collection) {\n\t\t\tcollection1name = collection;\n\t\t} else {\n\t\t\tcollection1name = DEFAULT_COLLECTION;\n\t\t}\n\t});\n</script>\n\n<div class=\"flex h-screen w-full flex-col\">\n\t<Viz />\n\t<div\n\t\tclass=\"absolute m-2 flex max-w-md flex-col rounded-lg border-2 border-solid border-gray-900 p-4 backdrop-blur\"\n\t>\n\t\t<h1 class=\"mb-4 text-2xl\">The Comparator</h1>\n\t\t<p class=\"mb-2 text-sm\">Choose two <s>or more</s> collections below to compare:</p>\n\t\t<CollectionsList prefix=\"Collection 1\" bind:selectedCollection={collection1name} />\n\t\t<CollectionsList prefix=\"Collection 2\" bind:selectedCollection={collection2name} />\n\t</div>\n\n\t<Settings />\n</div>\n"
  },
  {
    "path": "multimodal-embeddings/src/routes/viz/Image.svelte",
    "content": "<svelte:options accessors />\n\n<script lang=\"ts\">\n\timport { getThumb } from '$lib/firebase';\n\timport { T } from '@threlte/core';\n\timport { SpriteMaterial, Texture, SRGBColorSpace, Vector3 } from 'three';\n\timport { onMount } from 'svelte';\n\timport { spring } from 'svelte/motion';\n\timport { writable, type Writable } from 'svelte/store';\n\timport type { InteractionEvent } from '$lib/api';\n\timport type { ImageData } from '$lib/store';\n\n\texport let created;\n\texport let imageData: ImageData;\n\texport let texLoader;\n\texport let clickHandler;\n\n\t$: if (imageData.umap) {\n\t\t// console.log('update'); works\n\t\tpos.set(new Vector3(imageData.umap[0], imageData.umap[1], imageData.umap[2]));\n\t}\n\texport let pos: Writable<Vector3> = writable(new Vector3());\n\n\tlet texture: Texture;\n\tlet mat: SpriteMaterial;\n\n\tconst scale = spring(1);\n\n\tconst onEnter = (e: InteractionEvent) => {\n\t\te.stopPropagation();\n\t\tscale.set(1.4);\n\t};\n\n\tconst onLeave = (e: InteractionEvent) => {\n\t\te.stopPropagation();\n\t\tscale.set(1.0);\n\t};\n\n\tconst getTexture = async () => {\n\t\tconst imgUrl = await getThumb(imageData.doc.thumbPath);\n\t\ttexture = await texLoader.load(imgUrl);\n\t\ttexture.colorSpace = SRGBColorSpace; // important!\n\t\tmat = new SpriteMaterial({ map: texture });\n\t};\n\n\tonMount(() => {\n\t\tgetTexture();\n\t});\n</script>\n\n{#if mat}\n\t<T.Sprite\n\t\ton:create={({ ref, cleanup }) => {\n\t\t\tcreated(ref);\n\t\t\tcleanup(() => {\n\t\t\t\tif (texture) texture.dispose();\n\t\t\t});\n\t\t}}\n\t\tposition.x={$pos.x}\n\t\tposition.y={$pos.y}\n\t\tposition.z={$pos.z}\n\t\tscale={$scale}\n\t\ton:pointerenter={onEnter}\n\t\ton:pointerleave={onLeave}\n\t\ton:click={clickHandler}\n\t\tmaterial={mat}\n\t/>\n{/if}\n"
  },
  {
    "path": "multimodal-embeddings/src/routes/viz/Scene.svelte",
    "content": "<script lang=\"ts\">\n\timport { T, extend, useLoader, useTask, useThrelte } from '@threlte/core';\n\timport { interactivity } from '@threlte/extras';\n\timport { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';\n\timport { allImages, collections, remapFn, umapParams } from '$lib/store';\n\timport { UMAP } from 'umap-js';\n\timport { onMount } from 'svelte';\n\timport { writable } from 'svelte/store';\n\timport type { InteractionEvent } from '$lib/api';\n\timport Image from './Image.svelte';\n\timport * as THREE from 'three';\n\n\tconst texLoader = useLoader(THREE.TextureLoader);\n\tconst { renderer, invalidate } = useThrelte();\n\textend({ OrbitControls });\n\n\tconst enum State {\n\t\tLoading = 0,\n\t\tGeneratingUmap = 1,\n\t\tReady = 2\n\t}\n\tconst state = writable<State>(State.Loading);\n\n\tlet imageGroup = new THREE.Group();\n\tlet boundingBox = new THREE.Box3();\n\tlet boundingCenter = new THREE.Vector3();\n\tlet sphere: THREE.Mesh;\n\tlet creationCount = 0;\n\tlet clickRadius = 1.0;\n\tlet updateUmap = false;\n\n\t$: if ($collections.length > 0) {\n\t\t//collections changed!\n\t\tupdateUmap = true;\n\n\t\tallImages.set([...$collections[0].images, ...$collections[1].images]);\n\t}\n\n\t$: if ($allImages.length > 0 && updateUmap) {\n\t\tconsole.log('allImages updated', $allImages.length, $allImages[0].ref);\n\t\tupdateUMAP();\n\t}\n\n\t// umap always acts upon all images regardless of collection state\n\tconst updateUMAP = async () => {\n\t\tcreationCount = 0;\n\t\tstate.set(State.GeneratingUmap);\n\t\tconsole.log('updateUMAP', $umapParams);\n\t\tconst umap = new UMAP($umapParams);\n\t\tconst allEmbeddings = $allImages.map((id) => id.doc.embeddings!.value);\n\t\tconst mapped = await umap.fitAsync(allEmbeddings);\n\n\t\t// add to each image object, which surprisingly triggers reactivity\n\t\t$allImages.forEach((id, i) => {\n\t\t\tid.umap = mapped[i];\n\t\t});\n\t\tconsole.log('UMAP Updated');\n\t\tstate.set(State.Ready);\n\t\tupdateUmap = false;\n\n\t\t// invalidate();\n\n\t\t// we're done, so update url to match for sharing\n\t\t// $page.url.searchParams.set(COLLECTION_PARAM, collectionName);\n\t\t// goto(`?${$page.url.searchParams.toString()}`);\n\t};\n\n\tconst imageCreated = () => {\n\t\tcreationCount++;\n\t\tif (creationCount >= $allImages.length) {\n\t\t\tgetNewBoundingBox();\n\t\t}\n\t};\n\n\tconst getNewBoundingBox = () => {\n\t\tboundingBox = boundingBox.setFromPoints(imageGroup.children.map((img) => img.position));\n\t\tconsole.log('getNewBoundingBox', boundingBox);\n\n\t\t//for viewing convenience\n\t\tboundingBox.getCenter(boundingCenter);\n\t\tboundingCenter = boundingCenter.multiplyScalar(-1);\n\n\t\timageGroup.position.copy(boundingCenter);\n\t};\n\n\tconst onImageClick = (e: InteractionEvent) => {\n\t\te.stopPropagation();\n\t\tconsole.log(e.eventObject.position);\n\t\tsphere.position.copy(e.eventObject.position).add(boundingCenter);\n\t\tinvalidate();\n\n\t\tgetAllPointsWithinRadius(e.eventObject.position);\n\t};\n\n\tconst getAllPointsWithinRadius = (pos: THREE.Vector3) => {\n\t\t$allImages.forEach((item, i) => {\n\t\t\t// const dist = item\n\t\t});\n\t};\n\n\tonMount(() => {\n\t\tremapFn.set(updateUMAP);\n\t});\n\n\tinteractivity();\n</script>\n\n<T.AxesHelper />\n\n<T.PerspectiveCamera\n\tposition={[0, 0, 40]}\n\tmakeDefault\n\tlet:ref\n\ton:create={({ ref }) => {\n\t\tref.lookAt(0, 1, 0);\n\t}}\n>\n\t<T.OrbitControls args={[ref, renderer.domElement]} on:change={invalidate} />\n</T.PerspectiveCamera>\n\n<T is={imageGroup}>\n\t{#if !boundingBox.isEmpty()}\n\t\t<T\n\t\t\tis={THREE.Box3Helper}\n\t\t\targs={[boundingBox]}\n\t\t\tmaterial.depthTest={false}\n\t\t\tmaterial.opacity={0.25}\n\t\t\tmaterial.transparent={true}\n\t\t\tmaterial.color={'white'}\n\t\t/>\n\t{/if}\n\n\t{#if $state === State.Ready}\n\t\t{#each $allImages as imageData}\n\t\t\t<!-- {#if imageData.umap} -->\n\t\t\t<Image\n\t\t\t\tbind:this={imageData.ref}\n\t\t\t\tcreated={imageCreated}\n\t\t\t\tclickHandler={onImageClick}\n\t\t\t\t{texLoader}\n\t\t\t\t{imageData}\n\t\t\t/>\n\t\t\t<!-- {/if} -->\n\t\t{/each}\n\t{/if}\n</T>\n\n<T.Mesh let:ref on:create={({ ref }) => (sphere = ref)}>\n\t<!-- <T.SphereGeometry args={[clickRadius, 16, 16]} radius={clickRadius} /> -->\n\t<T.MeshBasicMaterial wireframe={true} color=\"red\" transparent={true} opacity={0.1} />\n</T.Mesh>\n"
  },
  {
    "path": "multimodal-embeddings/src/routes/viz/Settings.svelte",
    "content": "<script lang=\"ts\">\n\timport { Button, Folder, FpsGraph, Pane, Separator, Slider } from 'svelte-tweakpane-ui';\n\timport { remapFn, umapParams } from '$lib/store';\n\n\t// anchors\n\tlet learnMore: HTMLAnchorElement;\n\tlet firestorage: HTMLAnchorElement;\n</script>\n\n<div>\n\t<a\n\t\thidden\n\t\thref=\"https://pair-code.github.io/understanding-umap/\"\n\t\tbind:this={learnMore}\n\t\ttarget=\"_blank\">Learn about UMAP</a\n\t>\n\t<a\n\t\thidden\n\t\thref=\"https://firebase.corp.google.com/project/cl-demos/storage/cl-demos.appspot.com/files/~2Fmmembed\"\n\t\tbind:this={firestorage}\n\t\ttarget=\"_blank\">Demo cloud storage</a\n\t>\n\t<Pane position=\"fixed\" title=\"Settings\">\n\t\t<Folder title=\"UMAP\" expanded>\n\t\t\t<Slider label=\"Dimensions\" bind:value={$umapParams.nComponents} step={1} min={2} max={3} />\n\t\t\t<Slider label=\"Neighbors\" bind:value={$umapParams.nNeighbors} step={1} min={1} max={25} />\n\t\t\t<Slider label=\"Epochs\" bind:value={$umapParams.nEpochs} step={10} min={100} max={1000} />\n\t\t\t<Slider\n\t\t\t\tlabel=\"Min Distance\"\n\t\t\t\tbind:value={$umapParams.minDist}\n\t\t\t\tstep={0.1}\n\t\t\t\tmin={0.1}\n\t\t\t\tmax={3.0}\n\t\t\t/>\n\t\t\t<Slider label=\"Spread\" bind:value={$umapParams.spread} step={0.1} min={0.1} max={5.0} />\n\t\t\t<Button label=\"Update UMAP\" title=\"Remap\" on:click={$remapFn} />\n\t\t\t<Separator />\n\t\t\t<Button title=\"Add Images to Cloud Storage\" on:click={() => firestorage.click()} />\n\t\t\t<Separator />\n\t\t\t<Button title=\"Learn More About UMAP\" on:click={() => learnMore.click()} />\n\t\t</Folder>\n\t\t<Folder title=\"Rendering Activity\">\n\t\t\t<FpsGraph />\n\t\t</Folder>\n\t</Pane>\n</div>\n"
  },
  {
    "path": "multimodal-embeddings/src/routes/viz/Viz.svelte",
    "content": "<script lang=\"ts\">\n\timport { Canvas } from '@threlte/core';\n\timport Scene from './Scene.svelte';\n\n\t// necessary so Scene already inside the 'context' of Canvas\n\t// see threlte docs for reason:\n\t// https://threlte.xyz/docs/learn/basics/app-structure#context-not-available\n</script>\n\n<div class=\"flex-1\">\n\t<Canvas>\n\t\t<Scene />\n\t</Canvas>\n</div>\n<div class=\"absolute bottom-0\">\n\t<h1>Selected:</h1>\n</div>\n"
  },
  {
    "path": "multimodal-embeddings/svelte.config.js",
    "content": "import adapter from 'svelte-adapter-appengine';\nimport { vitePreprocess } from '@sveltejs/vite-plugin-svelte';\n\n/** @type {import('@sveltejs/kit').Config} */\nconst config = {\n\t// Consult https://kit.svelte.dev/docs/integrations#preprocessors\n\t// for more information about preprocessors\n\tpreprocess: vitePreprocess(),\n\n\tkit: {\n\t\t// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.\n\t\t// If your environment is not supported, or you settled on a specific environment, switch out the adapter.\n\t\t// See https://kit.svelte.dev/docs/adapters for more information about adapters.\n\t\tadapter: adapter({ nodejsRuntime: 20 })\n\t}\n};\n\nexport default config;\n"
  },
  {
    "path": "multimodal-embeddings/tailwind.config.ts",
    "content": "import { fontFamily } from \"tailwindcss/defaultTheme\";\nimport type { Config } from \"tailwindcss\";\n\nconst config: Config = {\n\tdarkMode: [\"class\"],\n\tcontent: [\"./src/**/*.{html,js,svelte,ts}\"],\n\tsafelist: [\"dark\"],\n\ttheme: {\n\t\tcontainer: {\n\t\t\tcenter: true,\n\t\t\tpadding: \"2rem\",\n\t\t\tscreens: {\n\t\t\t\t\"2xl\": \"1400px\"\n\t\t\t}\n\t\t},\n\t\textend: {\n\t\t\tcolors: {\n\t\t\t\tborder: \"hsl(var(--border) / <alpha-value>)\",\n\t\t\t\tinput: \"hsl(var(--input) / <alpha-value>)\",\n\t\t\t\tring: \"hsl(var(--ring) / <alpha-value>)\",\n\t\t\t\tbackground: \"hsl(var(--background) / <alpha-value>)\",\n\t\t\t\tforeground: \"hsl(var(--foreground) / <alpha-value>)\",\n\t\t\t\tprimary: {\n\t\t\t\t\tDEFAULT: \"hsl(var(--primary) / <alpha-value>)\",\n\t\t\t\t\tforeground: \"hsl(var(--primary-foreground) / <alpha-value>)\"\n\t\t\t\t},\n\t\t\t\tsecondary: {\n\t\t\t\t\tDEFAULT: \"hsl(var(--secondary) / <alpha-value>)\",\n\t\t\t\t\tforeground: \"hsl(var(--secondary-foreground) / <alpha-value>)\"\n\t\t\t\t},\n\t\t\t\tdestructive: {\n\t\t\t\t\tDEFAULT: \"hsl(var(--destructive) / <alpha-value>)\",\n\t\t\t\t\tforeground: \"hsl(var(--destructive-foreground) / <alpha-value>)\"\n\t\t\t\t},\n\t\t\t\tmuted: {\n\t\t\t\t\tDEFAULT: \"hsl(var(--muted) / <alpha-value>)\",\n\t\t\t\t\tforeground: \"hsl(var(--muted-foreground) / <alpha-value>)\"\n\t\t\t\t},\n\t\t\t\taccent: {\n\t\t\t\t\tDEFAULT: \"hsl(var(--accent) / <alpha-value>)\",\n\t\t\t\t\tforeground: \"hsl(var(--accent-foreground) / <alpha-value>)\"\n\t\t\t\t},\n\t\t\t\tpopover: {\n\t\t\t\t\tDEFAULT: \"hsl(var(--popover) / <alpha-value>)\",\n\t\t\t\t\tforeground: \"hsl(var(--popover-foreground) / <alpha-value>)\"\n\t\t\t\t},\n\t\t\t\tcard: {\n\t\t\t\t\tDEFAULT: \"hsl(var(--card) / <alpha-value>)\",\n\t\t\t\t\tforeground: \"hsl(var(--card-foreground) / <alpha-value>)\"\n\t\t\t\t}\n\t\t\t},\n\t\t\tborderRadius: {\n\t\t\t\tlg: \"var(--radius)\",\n\t\t\t\tmd: \"calc(var(--radius) - 2px)\",\n\t\t\t\tsm: \"calc(var(--radius) - 4px)\"\n\t\t\t},\n\t\t\tfontFamily: {\n\t\t\t\tsans: [...fontFamily.sans]\n\t\t\t}\n\t\t}\n\t},\n};\n\nexport default config;\n"
  },
  {
    "path": "multimodal-embeddings/tsconfig.json",
    "content": "{\n\t\"extends\": \"./.svelte-kit/tsconfig.json\",\n\t\"compilerOptions\": {\n\t\t\"allowJs\": true,\n\t\t\"checkJs\": true,\n\t\t\"esModuleInterop\": true,\n\t\t\"forceConsistentCasingInFileNames\": true,\n\t\t\"resolveJsonModule\": true,\n\t\t\"skipLibCheck\": true,\n\t\t\"sourceMap\": true,\n\t\t\"strict\": true,\n\t\t\"moduleResolution\": \"Bundler\"\n\t}\n\t// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias\n\t// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files\n\t//\n\t// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes\n\t// from the referenced tsconfig.json - TypeScript does not merge them in\n}\n"
  },
  {
    "path": "multimodal-embeddings/vite.config.ts",
    "content": "import { sveltekit } from '@sveltejs/kit/vite';\nimport { defineConfig } from 'vite';\n\nexport default defineConfig(({ command, mode }) => {\n\tconst isProduction = mode === 'production';\n\tconst isLocal = !isProduction && process.env.NODE_ENV !== 'test';\n\tconst useEmulator = isLocal && process.env.USE_EM === 'true';\n\n\t// Set in the npm run dev:emulate command\n\tconsole.log(`isProduction: ${isProduction}, isLocal: ${isLocal}, useEmulator: ${useEmulator} `);\n\n\treturn {\n\t\tplugins: [sveltekit()],\n\t\tssr: {\n\t\t\tnoExternal: ['three']\n\t\t},\n\t\tdefine: {\n\t\t\t'import.meta.env.VITE_USE_EMULATOR': useEmulator\n\t\t}\n\t};\n});\n"
  },
  {
    "path": "video-scrubber/.eslintrc.cjs",
    "content": "module.exports = {\n  root: true,\n  env: { browser: true, es2020: true },\n  extends: [\n    'eslint:recommended',\n    'plugin:@typescript-eslint/recommended',\n    'plugin:react-hooks/recommended',\n  ],\n  ignorePatterns: ['dist', '.eslintrc.cjs'],\n  parser: '@typescript-eslint/parser',\n  plugins: ['react-refresh'],\n  rules: {\n    'react-refresh/only-export-components': [\n      'warn',\n      { allowConstantExport: true },\n    ],\n  },\n}\n"
  },
  {
    "path": "video-scrubber/.gcloudignore",
    "content": "# This file specifies files that are *not* uploaded to Google Cloud\n# using gcloud. It follows the same syntax as .gitignore, with the addition of\n# \"#!include\" directives (which insert the entries of the given .gitignore-style\n# file at that point).\n#\n# For more information, run:\n#   $ gcloud topic gcloudignore\n#\n.gcloudignore\n# If you would like to upload your .git directory, .gitignore file or files\n# from your .gitignore file, remove the corresponding line\n# below:\n.git\n.gitignore\n.git*\n# Node.js dependencies:\nnode_modules/\n"
  },
  {
    "path": "video-scrubber/LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "video-scrubber/README.md",
    "content": "# Gemini Video Scrubber\n\nGVS is a simple demo application showcasing the power of Gemini 1.5 Pro's video understanding abilities.\n\n![GVS with my Oscar](public/gvs.png)\n\nSpecifically, it allows you to use Gemini to quickly analyze video content by prompting for associated timestamps, and making them clickable and playable as a stream. Additionally, any textual descriptions are added to the video element and played along their timestamps as well.\n\nJust make sure to prompt Gemini with a time-specific request like:\n\n```\nGive me 3 cute moments in this video of my cat oscar, returning the timestamps and descriptions. Be playful with your descriptions\n# check the \"Auto-format\" to append format-forcing language onto the prompt.\n\n// Model response:\n00:11 Oscar leaps for the toy like a graceful gymnast.\n00:16  Oscar's adorable struggle to catch the toy is too cute!\n00:20 Oscar's triumphant pounce on the toy, like a champion.\n```\n\nThen click \"Play all timestamps\" to watch a playthrough of the relevant timestamps (+ their duration and 'captions') to see if it's what you were looking for!\n\nThis example is playful but we've used this technique internally to find clips from longer videos (up to 1hr!) that are interesting to share across teams. Gemini as your own research assistant!\n\n## Run GVS (locally)\n\n> Hate reading? [Click here for the 🎬Video Walkthrough🎬](https://youtu.be/-kRxs7mrRXU)\n\nWe suggest you stick to local usage/development, as large video uploads tend to complicate deployments.\n\nLike any good prototype, getting started is simple:\n\n### Obtain a Gemini API Key\n\nFirst, create a local `.env` file with your `GEMINI_API_KEY=` obtained from [aistudio.withgoogle.com](https://aistudio.google.com/app/apikey) or your [cloud console](https://ai.google.dev/gemini-api/docs/api-key):\n\n```bash\n$ echo \"GEMINI_API_KEY='your_api_key'\" >> .env\n```\n\n### Install deps and run the project:\n\n```bash\n$ npm i\n$ npm run dev\n```\n\nOnce running at [localhost:3000](http://localhost:3000), the UI will ask for you to open up a video file.\n\nOnce opened, you can do one of two things:\n\n1. Paste a timestamp'ed response you might have gotten from another UI (like [AI Studio](https://aistudio.google.com))\n2. Click the Upload To Gemini button to send that video file to the [Gemini File API](https://ai.google.dev/gemini-api/docs/prompting_with_media?lang=node), which handles breaking the video\n   into its individual frames (at 1fps) and audio stream, creating a short lived identifier, and begin the\n   tokenization process.\n\nWe then simply poll the Files API until the video becomes `ACTIVE`, at which point we can use the video\nidentifier alongside any text prompt we want to [send to Gemini](https://ai.google.dev/gemini-api/docs/prompting_with_media?lang=node#generate-content-from-image). The File API handles caching the videos\ntokens for 48hrs, so any prompts sent from here on out won't need to re-upload or tokenize the video (as long as you continue to use the correct ID in your calls.)\n\n### Additional info\n\nThe UI can handle both single (##:##) and ranged (##:##-##:##) timestamps, with singular being padded by the \"Default clip duration\" option under the video. \"Pad clip start\" refers to extra time at the start of a timestamp, which we found helpful when doing single-word \"supercut\"-esque tests. (\"ai ai ai ai ai ai\")\n\n![Pad and duration](public/pad-duration.png)\n\nSee [src/Gemini.tsx](https://github.com/trippedout/gemini-video-scrubber/blob/main/src/Gemini.tsx) for the UI implementation and handling of timecodes in responses, and [server/gemini.js](https://github.com/trippedout/gemini-video-scrubber/blob/main/server/gemini.js) for the API calls we make. The server component is necessary as the File API uses node `fs` commands that are unavailable on the client. More _adventurous_ devs could by pass this by wrapping the [media.upload](https://ai.google.dev/api/rest/v1beta/media/upload) call itself :)\n\nMade in collab with the legendary [GrantCuster](https://github.com/GrantCuster), who won the React vs Svelte battle _this time_ 😀\n"
  },
  {
    "path": "video-scrubber/app.yaml",
    "content": "# Copyright 2024 Google LLC\n\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n\n#     https://www.apache.org/licenses/LICENSE-2.0\n\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nruntime: nodejs20\nservice: gvs\n\ndefault_expiration: \"0s\"\n"
  },
  {
    "path": "video-scrubber/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n  <meta charset=\"UTF-8\" />\n  <link rel=\"icon\" type=\"image/svg+xml\" href=\"/vite.svg\" />\n  <link rel=\"stylesheet\"\n    href=\"https://fonts.googleapis.com/css2?family=Google+Symbols:opsz,wght,FILL,GRAD@24,400,0,0\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  <title>Gemini Video Scrubber</title>\n</head>\n\n<body>\n  <div id=\"root\"></div>\n  <script src=\"https://accounts.google.com/gsi/client\"></script>\n  <script type=\"module\" src=\"/src/main.tsx\"></script>\n</body>\n\n</html>"
  },
  {
    "path": "video-scrubber/package.json",
    "content": "{\n  \"name\": \"gemini-video-scrubber\",\n  \"displayName\": \"Gemini Video Scrubber\",\n  \"description\": \"Simple demo app for Gemini multimodal capabilities with video understanding.\",\n  \"repository\": {\n    \"url\": \"https://github.com/trippedout/gemini-video-scrubber\"\n  },\n  \"contributors\": [\n    {\n      \"name\": \"Anthony Tripaldi\",\n      \"url\": \"http://github.com/trippedout\"\n    },\n    {\n      \"name\": \"Grant Custer\",\n      \"url\": \"http://github.com/grantcuster\"\n    }\n  ],\n  \"version\": \"0.1.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"start\": \"NODE_ENV=production node --env-file=.env server/index.js\",\n    \"dev\": \"node --env-file=.env server/index.js\",\n    \"preview\": \"vite preview\",\n    \"build\": \"vite build\",\n    \"deploy\": \"vite build && gcloud app deploy app.yaml --version demo\"\n  },\n  \"dependencies\": {\n    \"@google/generative-ai\": \"^0.11.3\",\n    \"@use-gesture/react\": \"^10.3.1\",\n    \"compromise\": \"^14.11.2\",\n    \"cors\": \"^2.8.5\",\n    \"express\": \"^4.2.0\",\n    \"jotai\": \"^2.6.0\",\n    \"jotai-effect\": \"^1.0.0\",\n    \"lucide-react\": \"^0.294.0\",\n    \"markdown-to-jsx\": \"^7.4.1\",\n    \"multer\": \"^1.4.5-lts.1\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-markdown\": \"^9.0.1\",\n    \"vite-express\": \"^0.14.1\"\n  },\n  \"devDependencies\": {\n    \"@tailwindcss/typography\": \"^0.5.10\",\n    \"@types/react\": \"^18.2.43\",\n    \"@types/react-dom\": \"^18.2.17\",\n    \"@typescript-eslint/eslint-plugin\": \"^6.14.0\",\n    \"@typescript-eslint/parser\": \"^6.14.0\",\n    \"@vitejs/plugin-react\": \"^4.2.1\",\n    \"autoprefixer\": \"^10.4.16\",\n    \"eslint\": \"^8.55.0\",\n    \"eslint-plugin-react-hooks\": \"^4.6.0\",\n    \"eslint-plugin-react-refresh\": \"^0.4.5\",\n    \"postcss\": \"^8.4.32\",\n    \"tailwindcss\": \"^3.3.6\",\n    \"typescript\": \"^5.2.2\",\n    \"vite\": \"^5.0.8\"\n  }\n}\n"
  },
  {
    "path": "video-scrubber/postcss.config.js",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nexport default {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "video-scrubber/server/gemini.js",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { GoogleGenerativeAI } from \"@google/generative-ai\";\nimport { GoogleAIFileManager } from \"@google/generative-ai/files\";\n\nconst KEY = process.env[\"GEMINI_API_KEY\"];\nconst fileManager = new GoogleAIFileManager(KEY);\nconst genAI = new GoogleGenerativeAI(KEY);\n\nexport const uploadVideo = async (file) => {\n    // TODO check if it exists already ... how?\n    try {\n        const uploadResult = await fileManager.uploadFile(file.path, {\n            displayName: file.originalname,\n            mimeType: file.mimetype,\n        })\n        console.log(`uploadComplete: ${uploadResult.file}`)\n        return uploadResult.file\n    } catch (error) {\n        console.error(error);\n        throw error;\n    }\n}\n\nexport const checkProgress = async (name) => {\n    try {\n        const result = await fileManager.getFile(name);\n        return result;\n    } catch (error) {\n        console.error(error);\n        return { error };\n    }\n}\n\nexport const promptVideo = async (processedVideo, prompt, model) => {\n    try {\n        const req = [\n            { text: prompt },\n            {\n                fileData: {\n                    mimeType: processedVideo.mimeType,\n                    fileUri: processedVideo.uri\n                }\n            },\n        ];\n        console.log(`promptVideo with ${model}`, req)\n        const result = await genAI.getGenerativeModel({ model }).generateContent(req);\n        console.log(`promptVideo response`, result.response.text())\n        return {\n            text: result.response.text(),\n            candidates: result.response.candidates,\n            feedback: result.response.promptFeedback\n        }\n    } catch (error) {\n        console.error(error)\n        return { error }\n    }\n}"
  },
  {
    "path": "video-scrubber/server/index.js",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport express from \"express\";\nimport ViteExpress from \"vite-express\";\nimport multer from \"multer\";\nimport { checkProgress, promptVideo, uploadVideo } from \"./gemini.js\";\n\nconst app = express();\napp.use(express.json());\n\n// need /tmp for appengine and gemini api to access\nconst upload = multer({ dest: \"/tmp/\" })\napp.post(\"/api/upload\", upload.single('video'), async (req, res) => {\n\n  try {\n    const file = req.file;\n    const resp = await uploadVideo(file)\n    console.log(resp);\n    res.json({ data: resp });\n\n  } catch (error) {\n    res.status(500).json({ error })\n  }\n})\n\napp.post(\"/api/progress\", async (req, res) => {\n  try {\n    console.log('/api/progress request', req.body)\n    const gemFileName = req.body.gemFileName\n    const progress = await checkProgress(gemFileName)\n    console.log('/api/progress', progress)\n    res.json(progress)\n  } catch (error) {\n    console.error(error)\n    res.status(500).json({ error })\n  }\n})\n\napp.post(\"/api/prompt\", async (req, res) => {\n  try {\n    const reqData = req.body\n    console.log('/api/prompt', reqData)\n    const videoResponse = await promptVideo(reqData.processedVideo, reqData.prompt, reqData.model)\n    res.json(videoResponse)\n  } catch (error) {\n    res.json({ error }, { status: 400 })\n  }\n})\n\n// eslint-disable-next-line no-undef\nconst port = process.env.NODE_ENV === \"production\" ? 8080 : 3000;\n\nViteExpress.listen(app, port, () => console.log(\"Server is listening...\"));\n"
  },
  {
    "path": "video-scrubber/src/Annotations.tsx",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { useAtom } from \"jotai\";\nimport { playPositionAtom, timestampDefaultDurationAtom, timestampTextAtom } from \"./atoms\";\nimport { parseTimestamps, timestampToSeconds } from \"./utils\";\n\nexport function Annotations() {\n  const [timestampText] = useAtom(timestampTextAtom);\n  const [playPosition] = useAtom(playPositionAtom);\n  const [defaultDuration] = useAtom(timestampDefaultDurationAtom);\n\n  const timestamps = parseTimestamps(timestampText);\n  const timestampSeconds = timestamps.map((tobject) => {\n    return {\n      start: timestampToSeconds(tobject.start),\n      end: tobject.end ? timestampToSeconds(tobject.end) : timestampToSeconds(tobject.start) + defaultDuration,\n      annotation: tobject.annotation\n    };\n  });\n\n  const activeAnnotation = timestampSeconds.find((range) => {\n    return playPosition >= range.start && playPosition <= range.end;\n  })?.annotation;\n\n  return <div className=\"relative\">\n    <div className=\"absolute bottom-0 left-0 w-full h-16 text-white text-center\">\n      {activeAnnotation ? <span className=\"text-lg bg-neutral-800 bg-opacity-80 px-1\">{activeAnnotation}</span> : null}\n    </div>\n  </div>;\n}\n"
  },
  {
    "path": "video-scrubber/src/App.tsx",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { useAtom } from \"jotai\";\nimport {\n  videoElAtom,\n} from \"./atoms\";\nimport { VideoInput } from \"./VideoInput\";\nimport { Video } from \"./Video\";\nimport { VideoState } from \"./VideoState\";\nimport { Controls } from \"./Controls\";\nimport { Timelines } from \"./Timelines\";\nimport { ClickableTimestamps } from \"./ClickableTimestamps\";\nimport { TimestampText } from \"./TimestampText\";\nimport { Annotations } from \"./Annotations\";\nimport { Gemini } from \"./Gemini\";\n\nfunction App() {\n  const [videoEl] = useAtom(videoElAtom);\n\n  return (\n    <>\n      <h1 className=\"text-3xl mb-6\">Gemini Video Scrubber</h1>\n      <Video />\n      {videoEl ?\n        <>\n          <Annotations />\n          <Controls />\n          <Timelines />\n          <ClickableTimestamps />\n          <VideoState />\n          <Gemini />\n          <TimestampText />\n        </> : <VideoInput />\n      }\n    </>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "video-scrubber/src/ClickableTimestamps.tsx",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { useAtom } from \"jotai\";\nimport {\n  padStartAtom,\n  timelineScrollRefAtom,\n  timeoutRefAtom,\n  timestampDefaultDurationAtom,\n  timestampTextAtom,\n  videoElAtom,\n  videoLengthAtom\n} from \"./atoms\";\nimport {\n  parseTimestamps,\n  timestampToSeconds\n} from \"./utils\";\nimport { secondWidth } from \"./consts\";\n\nexport function ClickableTimestamps() {\n  const [timestampText] = useAtom(timestampTextAtom);\n  const [videoEl] = useAtom(videoElAtom);\n  const player = videoEl!;\n  const [padStart] = useAtom(padStartAtom);\n  const [timelineScrollRef] = useAtom(timelineScrollRefAtom);\n  const [videoLength] = useAtom(videoLengthAtom);\n  const [timeoutRef] = useAtom(timeoutRefAtom);\n  const [defaultDuration] = useAtom(timestampDefaultDurationAtom);\n\n  const scrollEl = timelineScrollRef.current!;\n\n  const timestamps = parseTimestamps(timestampText);\n  const timestampSeconds = timestamps.map((tobject) => {\n    return {\n      start: timestampToSeconds(tobject.start),\n      end: tobject.end ? timestampToSeconds(tobject.end) : timestampToSeconds(tobject.start) + defaultDuration,\n      annotation: tobject.annotation\n    };\n  });\n\n  return (\n    <div className=\"flex flex-wrap gap-1 p-1\">\n      {timestamps.map((tobject, i) => {\n        return (\n          <div\n            key={`${tobject.start}-${i}`}\n            className=\"bg-neutral-600 hover:bg-neutral-500 select-none cursor-pointer px-1\"\n            onClick={() => {\n              const { start, end } = timestampSeconds[i];\n              const timelineWidth = videoLength * secondWidth;\n              const playPositionLeft = (start / player.duration) * timelineWidth;\n              if (playPositionLeft < scrollEl.scrollLeft) {\n                scrollEl.scrollLeft = playPositionLeft - 64;\n              } else if (playPositionLeft + 64 >\n                scrollEl.scrollLeft + scrollEl.clientWidth) {\n                scrollEl.scrollLeft =\n                  playPositionLeft - 64;\n              }\n              if (timeoutRef.current) {\n                window.clearTimeout(timeoutRef.current);\n              }\n              player.play();\n              player.currentTime = start - padStart;\n              timeoutRef.current = window.setTimeout(\n                () => {\n                  player.pause();\n                  player.currentTime = end;\n                },\n                (end - start + padStart) * 1000\n              );\n            }}\n          >\n            {tobject.start}{tobject.end ? `-${tobject.end}` : \"\"} {tobject.annotation ? `${tobject.annotation}` : \"\"}\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n\n"
  },
  {
    "path": "video-scrubber/src/ClipTimeline.tsx",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { useAtom, useSetAtom } from \"jotai\";\nimport {\n  playPositionAtom, timestampDefaultDurationAtom,\n  timestampTextAtom,\n  videoElAtom,\n  videoLengthAtom\n} from \"./atoms\";\nimport {\n  parseTimestamps,\n  timestampToSeconds\n} from \"./utils\";\nimport { useDrag } from \"@use-gesture/react\";\nimport { secondWidth } from \"./consts\";\n\n\nexport function ClipTimeline() {\n  const [videoEl] = useAtom(videoElAtom);\n  const setPlayPosition = useSetAtom(playPositionAtom);\n  const [playPosition] = useAtom(playPositionAtom);\n  const player = videoEl!;\n  const [timestampText] = useAtom(timestampTextAtom);\n  const [defaultDuration] = useAtom(timestampDefaultDurationAtom);\n  const [videoLength] = useAtom(videoLengthAtom);\n\n  const timestamps = parseTimestamps(timestampText);\n  const timestampSeconds = timestamps.map((tobject) => {\n    return {\n      start: timestampToSeconds(tobject.start),\n      end: tobject.end ? timestampToSeconds(tobject.end) : timestampToSeconds(tobject.start) + defaultDuration,\n      annotation: tobject.annotation\n    };\n  });\n\n  const timelineDrag = useDrag(({ active, xy: [x], currentTarget }) => {\n    if (active) {\n      const el = currentTarget as HTMLElement;\n      const offset = el.getBoundingClientRect().left;\n      const newPosition = ((x - offset) / el.clientWidth) * player.duration;\n      setPlayPosition(newPosition);\n      player.currentTime = newPosition;\n    }\n  });\n\n  const timelineWidth = videoLength * secondWidth;\n\n  return (\n    <div\n      className=\"bg-neutral-700 h-8 relative cursor-crosshair\"\n      {...timelineDrag()}\n      style={{\n        width: timelineWidth,\n      }}\n    >\n      {timestampSeconds.map((range, i) => {\n        const { start, end } = range;\n        const widthPercent = end !== undefined\n          ? (end - start) / videoLength\n          : defaultDuration / videoLength;\n        const visWidth = Math.max(widthPercent * timelineWidth, 2);\n        return (\n          <div\n            key={`${range}-${i}`}\n            className=\"absolute bg-neutral-500 h-8 pointer-events-none border-l-2 border-neutral-600\"\n            style={{\n              width: visWidth,\n              top: 0,\n              left: `${(start / player.duration) * 100}%`,\n            }}\n          ></div>\n        );\n      })}\n      <div\n        className=\"absolute bg-white h-8 pointer-events-none\"\n        style={{\n          width: 2,\n          top: 0,\n          marginLeft: -1,\n          left: `${(playPosition / player.duration) * 100}%`,\n        }}\n      ></div>\n    </div>\n  );\n}\n\n"
  },
  {
    "path": "video-scrubber/src/Controls.tsx",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { useAtom, useSetAtom } from \"jotai\";\nimport {\n  isPlayingAllAtom,\n  isPlayingAtom,\n  padStartAtom,\n  playPositionAtom,\n  timeoutRefAtom,\n  timestampDefaultDurationAtom,\n  timestampTextAtom,\n  videoElAtom,\n} from \"./atoms\";\nimport { parseTimestamps, timestampToSeconds } from \"./utils\";\n\nexport function Controls() {\n  const [videoEl] = useAtom(videoElAtom);\n  const [isPlaying] = useAtom(isPlayingAtom);\n  const [timestampDefaultDuration, setTimestampDefaultDuration] = useAtom(\n    timestampDefaultDurationAtom,\n  );\n  const [padStart, setPadStart] = useAtom(padStartAtom);\n  const [timestampText] = useAtom(timestampTextAtom);\n  const [timeoutRef] = useAtom(timeoutRefAtom);\n  const setPlayPosition = useSetAtom(playPositionAtom);\n  const [isPlayingAll, setIsPlayingAll] = useAtom(isPlayingAllAtom);\n  const [defaultDuration] = useAtom(timestampDefaultDurationAtom);\n\n  const timestamps = parseTimestamps(timestampText);\n  const timestampSeconds = timestamps.map((tobject) => {\n    return {\n      start: timestampToSeconds(tobject.start),\n      end: tobject.end\n        ? timestampToSeconds(tobject.end)\n        : timestampToSeconds(tobject.start) + defaultDuration,\n      annotation: tobject.annotation,\n    };\n  });\n\n  const player = videoEl!;\n\n  return (\n    <div className=\"flex justify-between select-none\">\n      <div className=\"flex gap-px\">\n        {isPlaying ? (\n          <button\n            className=\"px-2 w-16 bg-neutral-600 hover:bg-neutral-700 py-1\"\n            onClick={() => player.pause()}\n          >\n            Pause\n          </button>\n        ) : (\n          <button\n            className=\"px-2 w-16 py-1 bg-neutral-600 hover:bg-neutral-700\"\n            onClick={() => player.play()}\n          >\n            Play\n          </button>\n        )}\n        <button\n          className=\"px-2 py-1 bg-neutral-600 hover:bg-neutral-700\"\n          onClick={() => {\n            player.currentTime = 0;\n          }}\n        >\n          Reset\n        </button>\n        {isPlayingAll ? (\n          <button\n            className=\"px-2 py-1 bg-neutral-600 hover:bg-neutral-700\"\n            onClick={() => {\n              setIsPlayingAll(false);\n              if (timeoutRef.current) {\n                window.clearTimeout(timeoutRef.current);\n              }\n              player.pause();\n            }}\n          >\n            Stop all timestamps\n          </button>\n        ) : (\n          <button\n            className=\"px-2 py-1 bg-neutral-600 hover:bg-neutral-700\"\n            onClick={() => {\n              setIsPlayingAll(true);\n              function getNextTimestamp() {\n                for (const range of timestampSeconds) {\n                  const { start, end } = range;\n                  if (start > player.currentTime) {\n                    return [start, end];\n                  }\n                }\n                return undefined;\n              }\n              function playTimestamps() {\n                const nextTimestamp = getNextTimestamp();\n                if (nextTimestamp !== undefined) {\n                  const [nextStart] = nextTimestamp;\n                  const nextEnd =\n                    nextTimestamp[1] ?? nextStart + timestampDefaultDuration;\n                  setPlayPosition(nextStart - padStart);\n                  player.currentTime = nextStart - padStart;\n                  player.play();\n                  timeoutRef.current = window.setTimeout(\n                    () => {\n                      playTimestamps();\n                    },\n                    (nextEnd - nextStart + padStart) * 1000,\n                  );\n                } else {\n                  player.pause();\n                  setIsPlayingAll(false);\n                }\n              }\n              playTimestamps();\n            }}\n          >\n            Play all timestamps\n          </button>\n        )}\n      </div>\n      <div className=\"flex gap-1\">\n        <div className=\"px-1 py-1 flex items-center gap-1\">\n          <div className=\"text-sm text-neutral-300\">pad clip start:</div>\n          <input\n            type=\"number\"\n            step=\"0.1\"\n            className=\"w-14 px-1 bg-neutral-300 text-black text-sm\"\n            value={padStart}\n            onChange={(e) => {\n              const value = Number(e.target.value);\n              setPadStart(value);\n            }}\n          />\n        </div>\n        <div className=\"px-1 py-1 flex items-center gap-1\">\n          <div className=\"text-sm text-neutral-300\">default clip duration:</div>\n          <input\n            type=\"number\"\n            step=\"0.1\"\n            className=\"w-14 px-1 bg-neutral-300 text-black text-sm\"\n            value={timestampDefaultDuration}\n            onChange={(e) => {\n              const value = Number(e.target.value);\n              setTimestampDefaultDuration(value);\n            }}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "video-scrubber/src/Gemini.tsx",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { useEffect, useState } from \"react\";\nimport { useAtom } from \"jotai\";\nimport {\n  promptAtom,\n  videoFileAtom,\n  timestampTextAtom,\n  storedVideosAtom,\n  processedVideoAtom,\n} from \"./atoms\";\nimport type { ProcessedVideo } from \"./atoms\"\nimport { FileMetadataResponse } from \"@google/generative-ai/files\";\n\nconst post = async (url: string, body: string | FormData) => {\n  const opts: RequestInit = {\n    method: \"POST\",\n    body,\n  };\n  if (typeof body === \"string\") {\n    opts.headers = {\n      \"Content-Type\": \"application/json\",\n    };\n  }\n  const f = await fetch(url, opts);\n  return await f.json();\n};\n\nexport function Gemini() {\n  const [videoFile] = useAtom(videoFileAtom);\n  const [prompt, setPrompt] = useAtom(promptAtom);\n  const [, setTimestampText] = useAtom(timestampTextAtom);\n  const [storedVideos, setStoredVideos] = useAtom(storedVideosAtom);\n  const [processedVideo, setProcessedVideo] = useAtom(processedVideoAtom);\n\n  useEffect(() => {\n    // check stored videos to see if we already have one available\n    const storedVideo = findVideoFromFile(videoFile!)\n    if (storedVideo) {\n      setProcessedVideo(storedVideo)\n      setState(UploadState.Available)\n    }\n  }, [videoFile])\n\n  const findVideoFromFile = (videoFile: File) => {\n    console.log('findVideoFromFile:', videoFile)\n    let foundVid = null;\n    storedVideos.forEach((vid) => {\n      if (vid.name === videoFile.name) {\n        // name might stay same but other properties updated\n        if (vid.lastModified === videoFile.lastModified && vid.size === videoFile.size) {\n          foundVid = vid;\n        }\n      }\n    })\n    return foundVid;\n  }\n\n  const enum UploadState {\n    Waiting = \"\",\n    Uploading = \"Uploading...\",\n    Processing = \"Processing...\",\n    Processed = \"Processed!\",\n    Failure = \"Upload failed, please try again.\",\n    Available = \"Found processed video - prompt away!\"\n  }\n  // should probably be an atom...\n  const [state, setState] = useState<UploadState>(UploadState.Waiting);\n\n  const enum MODEL {\n    Gemini = \"gemini-1.5-pro-latest\",\n    Flash = \"gemini-1.5-flash-latest\"\n  }\n  const [model, setModel] = useState(MODEL.Gemini);\n\n  const DEFAULT_PROMPT = 'Give me the 5 cutest moments in this cat video, with their timestamps'\n  const CONCISE_PROMPT = \"ONLY return the timestamps (in the format ##:##) and the descriptions, with no added commentary.\"\n  const [useConcise, setUseConcise] = useState(true);\n  const [sendingPrompt, setSendingPrompt] = useState(false);\n\n  const promptable = (state: UploadState) => (state != UploadState.Processed && state != UploadState.Available) || sendingPrompt;\n\n  const handleUploadClick = async () => {\n    console.log(\"upload:\", videoFile);\n    try {\n      if (videoFile) {\n        setState((_) => UploadState.Uploading);\n        const formData = new FormData();\n        formData.set(\"video\", videoFile);\n        const resp = await post(\"/api/upload\", formData);\n        console.log(\"handleUploadClick::uploadResult()\", resp.data);\n        setState((_) => UploadState.Processing);\n        checkProcessing(resp.data.name);\n      }\n    } catch (err) {\n      console.error(\"Error Uploading Video\", err);\n    }\n  };\n\n  // combine info from chosen video file and the upload processing results\n  const getVideoFromResult = (result: FileMetadataResponse): ProcessedVideo => {\n    return {\n      name: videoFile!.name,\n      lastModified: videoFile!.lastModified,\n      size: videoFile!.size,\n      expirationTime: result.expirationTime,\n      uri: result.uri,\n      mimeType: result.mimeType\n    }\n  }\n\n  const checkProcessing = async (name: string) => {\n    setTimeout(async () => {\n      const progressResult = await post(\n        \"/api/progress\",\n        JSON.stringify({ gemFileName: name }),\n      );\n      const state = progressResult.state;\n      console.log(\"checkProcessing:\", progressResult);\n      if (state == \"ACTIVE\") {\n        const processedVideo = getVideoFromResult(progressResult)\n        setProcessedVideo(processedVideo)\n        setStoredVideos([...storedVideos, processedVideo])\n        setState((_) => UploadState.Processed);\n      } else if (state == \"FAILED\") {\n        setState((_) => UploadState.Failure);\n      } else {\n        setState((_) => UploadState.Processing);\n        checkProcessing(progressResult.name);\n      }\n    }, 5000);\n  };\n\n  const handlePromptKeyDown = (event: React.KeyboardEvent) => {\n    if (event.key === \"Enter\" && event.metaKey) {\n      sendPrompt();\n    }\n  };\n\n  const sendPrompt = async () => {\n    setSendingPrompt(true);\n\n    let p = prompt;\n    if (p.length == 0) { // empty\n      p = DEFAULT_PROMPT\n    }\n\n    if (useConcise) {\n      p += '\\n' + CONCISE_PROMPT;\n    }\n\n    const response = await post(\n      \"/api/prompt\",\n      JSON.stringify({\n        processedVideo,\n        prompt: p,\n        model\n      }),\n    );\n    setSendingPrompt(false);\n    const modelResponse = response.text;\n    setTimestampText(modelResponse.trim());\n  };\n\n  const showConcise = () => {\n    alert('Appends simple prompt for force response to only include timestamps and descriptions: \"' +\n      CONCISE_PROMPT + '\"'\n    )\n  }\n\n  return (\n    <div className=\"mt-4\">\n      {state != UploadState.Available &&\n        <>\n          <button\n            disabled={state == UploadState.Uploading || state == UploadState.Processing}\n            className=\"bg-gray-500 enabled:hover:bg-gray-800 disabled:opacity-25 mr-4 font-bold py-2 px-4 rounded mb-4\"\n            onClick={handleUploadClick}\n          >\n            Upload to Gemini\n          </button>\n          <span>{state}</span>\n        </>\n      }\n      <div className=\"flex mb-4\">\n        <div className=\"w-full relative mr-4\">\n          <textarea\n            disabled={sendingPrompt}\n            className=\"w-full h-24 bg-neutral-800 p-2 pr-32 focus:outline-none flex-auto mr-4\"\n            name=\"prompt\"\n            placeholder={DEFAULT_PROMPT}\n            value={prompt}\n            onKeyDown={handlePromptKeyDown}\n            onChange={(e) => setPrompt(e.target.value)}\n          />\n          <button\n            className=\"absolute top-2 right-2 bg-gray-500 enabled:hover:bg-gray-800 disabled:opacity-25 font-bold py-2 px-4 rounded\"\n            disabled={promptable(state)}\n            onClick={sendPrompt}\n          >Prompt!\n          </button>\n        </div>\n        <div className=\"min-w-48\">\n          <div className=\"flex flex-col mb-4\">\n            <div className=\"flex\">\n              <input className=\"mr-4\" type=\"radio\" id=\"gemini\" name=\"gemini\" value={MODEL.Gemini} checked={model == MODEL.Gemini} onChange={() => { setModel(MODEL.Gemini) }} />\n              <label htmlFor=\"gemini\">Gemini 1.5 Pro</label>\n            </div>\n            <div className=\"flex\">\n              <input className=\"mr-4\" type=\"radio\" id=\"flash\" name=\"flash\" value={MODEL.Flash} checked={model == MODEL.Flash} onChange={() => { setModel(MODEL.Flash) }} />\n              <label htmlFor=\"flash\">Gemini Flash</label>\n            </div>\n          </div>\n          <div className=\"flex items-center\">\n            <input type=\"checkbox\" className=\"mr-2 h-10\" checked={useConcise} onChange={() => setUseConcise(!useConcise)} />\n            <span>Auto-format</span>\n            <span className=\"google-symbols ml-2 cursor-pointer\" onClick={showConcise}>help</span>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "video-scrubber/src/PlayTimeline.tsx",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { useAtom, useSetAtom } from \"jotai\";\nimport {\n  playPositionAtom, videoElAtom,\n  videoLengthAtom\n} from \"./atoms\";\nimport { secondsToTimestamp } from \"./utils\";\nimport { useDrag } from \"@use-gesture/react\";\nimport { secondWidth } from \"./consts\";\n\n\nexport function PlayTimeline() {\n  const [videoEl] = useAtom(videoElAtom);\n  const setPlayPosition = useSetAtom(playPositionAtom);\n  const [playPosition] = useAtom(playPositionAtom);\n  const [videoLength] = useAtom(videoLengthAtom);\n  const player = videoEl!;\n\n  const timelineDrag = useDrag(({ active, xy: [x], currentTarget }) => {\n    if (active) {\n      const el = currentTarget as HTMLElement;\n      const offset = el.getBoundingClientRect().left;\n      const newPosition = ((x - offset) / el.clientWidth) * player.duration;\n      setPlayPosition(newPosition);\n      player.currentTime = newPosition;\n    }\n  });\n\n  const timelineWidth = videoLength * secondWidth;\n\n  return (\n    <div\n      className=\"bg-neutral-400 h-0 relative flex justify-end touch-none cursor-crosshair\"\n      {...timelineDrag()}\n      style={{\n        width: timelineWidth,\n      }}\n    >\n      <div\n        className=\"absolute bg-neutral-800 h-6 pointer-events-none\"\n        style={{\n          width: 2,\n          top: 0,\n          marginLeft: -1,\n          left: `${(playPosition / player.duration) * 100}%`,\n        }}\n      ></div>\n      <div className=\"fixed right-0 -mt-[26px] flex text-sm items-center text-neutral-400 font-mono pointer-events-none select-none px-2\">\n        <div>{secondsToTimestamp(Math.round(playPosition))}</div> /{\" \"}\n        <div>{secondsToTimestamp(Math.round(videoLength))}</div>\n      </div>\n    </div>\n  );\n}\n\n"
  },
  {
    "path": "video-scrubber/src/Timelines.tsx",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { useAtom } from \"jotai\";\nimport { timelineScrollRefAtom } from \"./atoms\";\nimport { ClipTimeline } from \"./ClipTimeline\";\n\nexport function Timelines() {\n  const [timelineScrollRef] = useAtom(timelineScrollRefAtom);\n  return (\n    <div\n      ref={(el) => {\n        timelineScrollRef.current = el;\n      }}\n      className=\"overflow-x-auto mt-px pb-4\"\n    >\n      <ClipTimeline />\n    </div>\n  );\n}\n\n"
  },
  {
    "path": "video-scrubber/src/TimestampText.tsx",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { useAtom } from \"jotai\";\nimport { timestampTextAtom } from \"./atoms\";\n\nexport function TimestampText() {\n  const [timestampText, setTimestampText] = useAtom(timestampTextAtom);\n  return (\n    <textarea\n      className=\"w-full h-48 bg-neutral-800 p-2 focus:outline-none\"\n      placeholder=\"Or paste your timestamps here from anywhere\"\n      onChange={(e) => {\n        const text = e.target.value;\n        setTimestampText(text);\n      }}\n      value={timestampText}\n    >\n    </textarea>\n  );\n}\n\n"
  },
  {
    "path": "video-scrubber/src/Video.tsx",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { useAtom } from \"jotai\";\nimport { videoElAtom, videoSrcAtom } from \"./atoms\";\n\nexport function Video() {\n  const [, setVideoEl] = useAtom(videoElAtom);\n  const [videoSrc] = useAtom(videoSrcAtom);\n\n  return (\n    <div className=\"w-full flex justify-center\">\n      {videoSrc ? (\n        <video\n          className=\"max-w-full max-h-[50vh]\"\n          ref={(ref) => {\n            setVideoEl(ref);\n          }}\n          src={videoSrc} />\n      ) : null}\n    </div>\n  );\n}\n\n"
  },
  {
    "path": "video-scrubber/src/VideoInput.tsx",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { useAtom } from \"jotai\";\nimport { videoSrcAtom, videoFileAtom, storedVideosAtom } from \"./atoms\";\nimport { useEffect } from \"react\";\n\nexport function VideoInput() {\n  const [, setVideoSrc] = useAtom(videoSrcAtom);\n  const [, setVideoFile] = useAtom(videoFileAtom);\n  const [storedVideos, setStoredVideos] = useAtom(storedVideosAtom);\n\n  useEffect(() => {\n    // check for and remove any procsesed videos that have expired from localstore\n    let toRemove: number[] = []\n    if (storedVideos.length) {\n      storedVideos.forEach((vid, i) => {\n        if (new Date(vid.expirationTime) < new Date()) {\n          toRemove.push(i)\n        }\n      })\n    }\n    if (toRemove.length) {\n      const remaining = storedVideos.filter((_, index) => !toRemove.includes(index))\n      setStoredVideos(remaining)\n    }\n  }, [storedVideos])\n\n  return (\n    <div>\n      <input\n        className=\"bg-neutral-700 p-4 pl-4 pr-8 mb-4\"\n        type=\"file\"\n        accept=\"video/*\"\n        onChange={(e) => {\n          if (e.target.files) {\n            const file = e.target.files[0];\n            const src = URL.createObjectURL(file);\n            console.log(src, file)\n            setVideoFile(file)\n            setVideoSrc(src);\n          }\n        }} />\n\n      {storedVideos.length > 0 &&\n        <>\n          <h2 className=\"text-xl\">Successfully processed videos:</h2>\n          <p className=\"text-med text-gray-400\">\n            Please select again above to attempt reuse - videos are <a href=\"https://ai.google.dev/gemini-api/docs/prompting_with_media?lang=node\" target=\"_blank\">available for ~48 hours</a>\n          </p>\n          <ol className=\"list-disc p-4 pl-8\">\n            {storedVideos.map((vid) =>\n              <li key={vid.uri}>\n                <b>{vid.name}</b> modified on <i>{new Date(vid.lastModified).toDateString()}</i> <span>available until {new Date(vid.expirationTime).toDateString()}</span>\n              </li>\n            )}\n          </ol>\n        </>\n      }\n    </div >\n  );\n}\n\n"
  },
  {
    "path": "video-scrubber/src/VideoState.tsx",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { useAtom } from \"jotai\";\nimport {\n  isPlayingAtom, playPositionAtom,\n  timelineScrollRefAtom, videoElAtom,\n  videoLengthAtom\n} from \"./atoms\";\nimport { useEffect } from \"react\";\nimport { secondWidth } from \"./consts\";\n\nexport function VideoState() {\n  const [videoEl] = useAtom(videoElAtom);\n  const [, setIsPlaying] = useAtom(isPlayingAtom);\n  const [, setPlayPosition] = useAtom(playPositionAtom);\n  const [videoLength, setVideoLength] = useAtom(videoLengthAtom);\n  const [timelineScrollRef] = useAtom(timelineScrollRefAtom);\n\n  const player = videoEl!;\n\n  const timelineWidth = videoLength * secondWidth;\n\n  useEffect(() => {\n    function updateIsPlaying() {\n      setIsPlaying(!player.paused);\n    }\n    function updatePlayPosition() {\n      // Update this here so we don't get weird jumps\n      if (timelineScrollRef.current) {\n        const scrollEl = timelineScrollRef.current;\n        const playPositionLeft = (player.currentTime / player.duration) * timelineWidth;\n        if (playPositionLeft < scrollEl.scrollLeft) {\n          scrollEl.scrollLeft = playPositionLeft - 64;\n        } else if (playPositionLeft + 64 >\n          scrollEl.scrollLeft + scrollEl.clientWidth) {\n          scrollEl.scrollLeft = playPositionLeft - 64;\n        }\n      }\n      setPlayPosition(Math.round(player.currentTime * 20) / 20);\n\n    }\n    function updateVideoLength() {\n      setVideoLength(player.duration);\n    }\n    player.addEventListener(\"play\", updateIsPlaying);\n    player.addEventListener(\"pause\", updateIsPlaying);\n    player.addEventListener(\"ended\", updateIsPlaying);\n    player.addEventListener(\"timeupdate\", updatePlayPosition);\n    player.addEventListener(\"loadedmetadata\", updateVideoLength);\n    return () => {\n      player.removeEventListener(\"play\", updateIsPlaying);\n      player.removeEventListener(\"pause\", updateIsPlaying);\n      player.removeEventListener(\"ended\", updateIsPlaying);\n      player.removeEventListener(\"timeupdate\", updatePlayPosition);\n      player.removeEventListener(\"loadedmetadata\", updateVideoLength);\n    };\n  }, [player, timelineScrollRef, videoLength]);\n\n  return null;\n}\n\n"
  },
  {
    "path": "video-scrubber/src/atoms.tsx",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { atomWithStorage, } from \"jotai/utils\"\nimport { atom } from \"jotai\";\nimport { AsyncStorage } from \"jotai/vanilla/utils/atomWithStorage\";\n\nexport const videoFileAtom = atom<File | null>(null);\nexport const videoSrcAtom = atom<string | null>(null);\nexport const videoElAtom = atom<HTMLVideoElement | null>(null);\nexport const isPlayingAtom = atom<boolean>(false);\nexport const playPositionAtom = atom<number>(0);\nexport const videoLengthAtom = atom<number>(0);\nexport const timestampTextAtom = atom(\"\");\nexport const timestampDefaultDurationAtom = atom<number>(1.5);\nexport const padStartAtom = atom<number>(0.2);\nexport const timelineScrollRefAtom = atom<{ current: HTMLDivElement | null }>({\n  current: null,\n});\nexport const timeoutRefAtom = atom<{ current: number }>({ current: 0 });\nexport const isPlayingAllAtom = atom<boolean>(false);\nexport const promptAtom = atom<string>(\"\");\n\n// Type and custom storage for use with our localStorage video store,\n// which is shared by Gemini and VideoInput\nexport type ProcessedVideo = {\n  name: string,\n  lastModified: number,\n  size: number\n  uri: string,\n  expirationTime: string,\n  mimeType: string,\n}\n\nconst videoStorage: AsyncStorage<ProcessedVideo[]> = {\n  getItem(key, _) {\n    const item = localStorage.getItem(key)\n    if (item) {\n      return JSON.parse(item)\n    } else {\n      return []\n    }\n  },\n  setItem(key, val) {\n    return new Promise<void>((res, rej) => {\n      try {\n        localStorage.setItem(key, JSON.stringify(val))\n        res();\n      } catch (error) {\n        rej(error);\n      }\n    })\n  },\n  removeItem(key) {\n    return new Promise<void>((res, rej) => {\n      try {\n        localStorage.removeItem(key);\n        res();\n      } catch (error) {\n        rej(error);\n      }\n    })\n  },\n}\nexport const storedVideosAtom = atomWithStorage('processedVideos', [], videoStorage)\nexport const processedVideoAtom = atom<ProcessedVideo | null>(null)"
  },
  {
    "path": "video-scrubber/src/consts.tsx",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nexport const secondWidth = 12\n"
  },
  {
    "path": "video-scrubber/src/index.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nhtml {\n  background: black;\n  color: white;\n}\n\n#root {\n  max-width: 1280px;\n  margin: 0 auto;\n  padding: 2rem;\n}"
  },
  {
    "path": "video-scrubber/src/main.tsx",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport App from './App.tsx'\nimport './index.css'\n\nReactDOM.createRoot(document.getElementById('root')!).render(\n  <React.StrictMode>\n    <App />\n  </React.StrictMode>,\n)\n"
  },
  {
    "path": "video-scrubber/src/utils.tsx",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nexport function secondsToTimestamp(seconds: number): string {\n  let minutes = Math.floor(seconds / 60);\n  let hours = null;\n  if (minutes >= 60) {\n    hours = Math.floor(minutes / 60);\n    minutes = minutes % 60;\n  }\n  seconds = Math.floor((seconds % 60) * 10) / 10;\n  return `${hours ? hours + \":\" : \"\"}${minutes}:${seconds < 10 ? \"0\" : \"\"\n    }${seconds}`;\n}\n\ntype ParsedTimestamp = {\n  start: string;\n  end: string | null;\n  annotation: string | null;\n};\nexport function parseTimestamps(text: string): ParsedTimestamp[] {\n  const parsed = text.split(\"\\n\").map((timestamp) => {\n    const object: ParsedTimestamp = { start: '', end: null, annotation: null };\n    let toParse = timestamp.trim();\n    // handle annotation\n    if (toParse.includes(\" \")) {\n      const annotationCheck = toParse.split(\" \").slice(1).join(\" \").trim();\n      if (annotationCheck.length > 0) {\n        toParse = toParse.split(\" \")[0].trim();\n        object.annotation = annotationCheck;\n      } else {\n        toParse = toParse.trim();\n      }\n    }\n    // handle start and end\n    if (toParse.includes(\"-\")) {\n      const [start, end] = toParse.split(\"-\").map((t) => t.trim());\n      object.start = start;\n      object.end = end;\n    } else {\n      object.start = toParse;\n    }\n    return object;\n  });\n  return parsed;\n}\n\nexport function timestampToSeconds(timestamp: string): number {\n  const splits = timestamp.split(\":\");\n  if (splits.length === 3) {\n    return (\n      Number(splits[0]) * 3600 + Number(splits[1]) * 60 + Number(splits[2])\n    );\n  } else if (splits.length === 2) {\n    return Number(splits[0]) * 60 + Number(splits[1]);\n  } else {\n    return Number(splits[0]);\n  }\n}\n"
  },
  {
    "path": "video-scrubber/src/vite-env.d.ts",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "video-scrubber/tailwind.config.js",
    "content": "/**\nCopyright 2024 Google LLC\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n    https://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n */\n/** @type {import('tailwindcss').Config} */\nexport default {\n  content: [\n    \"./pages/**/*.{ts,tsx}\",\n    \"./components/**/*.{ts,tsx}\",\n    \"./app/**/*.{ts,tsx}\",\n    \"./src/**/*.{ts,tsx}\",\n  ],\n  theme: {\n    extend: {\n      colors: {\n        blueAccent: {\n          DEFAULT: \"#293EFF\",\n        },\n        purpleAccent: {\n          DEFAULT: \"#9FA9FF\",\n        },\n        gBlack: {\n          600: \"#6F6C72\",\n          800: \"#1E1F20\",\n          900: \"#000000\",\n        },\n        gWhite: {\n          100: \"#ffffff\",\n          200: \"#E4E3E3\",\n          400: \"#A7A7A7\",\n        },\n      },\n    },\n  },\n  plugins: [require(\"@tailwindcss/typography\")],\n};\n"
  },
  {
    "path": "video-scrubber/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\n      \"ES2020\",\n      \"DOM\",\n      \"DOM.Iterable\"\n    ],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"noFallthroughCasesInSwitch\": true\n  },\n  \"include\": [\n    \"src\",\n    \"server\"\n  ],\n  \"references\": [\n    {\n      \"path\": \"./tsconfig.node.json\"\n    }\n  ]\n}"
  },
  {
    "path": "video-scrubber/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowSyntheticDefaultImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "video-scrubber/vite.config.ts",
    "content": "// Copyright 2024 Google LLC\n\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n\n//     https://www.apache.org/licenses/LICENSE-2.0\n\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\nimport { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n  plugins: [react()],\n});\n"
  },
  {
    "path": "voice-cursor/.gcloudignore",
    "content": "# This file specifies files that are *not* uploaded to Google Cloud\n# using gcloud. It follows the same syntax as .gitignore, with the addition of\n# \"#!include\" directives (which insert the entries of the given .gitignore-style\n# file at that point).\n#\n# For more information, run:\n#   $ gcloud topic gcloudignore\n#\n.gcloudignore\n# If you would like to upload your .git directory, .gitignore file or files\n# from your .gitignore file, remove the corresponding line\n# below:\n.git\n.gitignore\n\n# Node.js dependencies:\nnode_modules/"
  },
  {
    "path": "voice-cursor/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n.yarn/install-state.gz\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# local env files\n.env*.local\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n\n# For Google Cloud deployment\napp.yaml\n!app.yaml.template "
  },
  {
    "path": "voice-cursor/CONTRIBUTING.md",
    "content": "# How to contribute\n\nWe'd love to accept your patches and contributions to this project.\n\n## Before you begin\n\nSign our Contributor License Agreement\nContributions to this project must be accompanied by a Contributor License Agreement (CLA). You (or your employer) retain the copyright to your contribution; this simply gives us permission to use and redistribute your contributions as part of the project.\n\nIf you or your current employer have already signed the Google CLA (even if it was for a different project), you probably don't need to do it again.\n\nVisit https://cla.developers.google.com/ to see your current agreements or to sign a new one.\n\n### Review our community guidelines\nThis project follows Google's Open Source Community Guidelines.\n\n## Contribution process\n\n###Code reviews\nAll submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose. Consult GitHub Help for more information on using pull requests."
  },
  {
    "path": "voice-cursor/LICENSE",
    "content": "\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License."
  },
  {
    "path": "voice-cursor/README.md",
    "content": "# Voice Cursor\n\nAn experimental text editor showcasing Gemini 2.0's Native Audio capabilities. Built on top of [Novel](https://novel.sh), Voice Cursor demonstrates how Gemini's new text-to-speech API can be integrated into a text editor for fluid, in context voice generation.\n\n![Voice Cursor Demo](readme/multiline.gif)\n\n## What is Gemini 2.0 Native Audio?\n\nGemini 2.0 introduces multilingual native audio output - a powerful new capability that lets developers generate natural-sounding speech directly from the Gemini API. This project demonstrates how to use this feature in a real application.\n\n🎥 [Watch the Gemini 2.0 Native Audio Demo](https://www.youtube.com/watch?v=qE673AY-WEI) 🔊\n\n## Features\n\n- 🎯 **Native Gemini Audio**: Direct integration with Gemini 2.0's text-to-speech capabilities\n- 🎭 **Rich Voice Options**: 8 different Gemini voices with distinct characteristics\n- 😊 **Emotional Control**: 15 different tones to shape how Gemini expresses the text\n- 🎨 **Visual Integration**: Color-coded highlights show which voice and tone were used\n- ⚡ **Instant Generation**: Quick audio synthesis powered by Gemini's latest model\n\n## Getting Started\n\n### 1. Clone this repository and install dependencies:\n\n```bash\ngit clone https://github.com/googlecreativelab/gemini-demos/voice-cursor\n```\n\n```bash\nnpm install\n```\n\n### 2. Create a `.env.local` file with your AI Studio API key:\n\nGet your API key from [Google AI Studio](https://aistudio.google.com/apikey)\n\n```env\nNEXT_PUBLIC_GEMINI_API_KEY=your_api_key_here\n```\n\n### 3. Start the development server:\n\n```bash\nnpm run dev\n```\n\nOpen [http://localhost:3000](http://localhost:3000) and start highlighting text!\n\n## How It Works\n\nThe magic happens in [`src/components/editor/selectors/voice-popover.tsx`](src/components/editor/selectors/voice-popover.tsx). When text is highlighted, we construct a prompt that includes both the text and desired emotional tone:\n\nThis is then sent to Gemini 2.0's API with audio generation enabled.\n\n### Tone Options\n\nThe voice cursor supports various emotional tones through the [`src/lib/tone-options.ts`](src/lib/tone-options.ts) file. Each tone has an emoji and a transformation function that constructs the prompt:\n\nEdit, add, or remove tones in [`src/lib/tone-options.ts`](src/lib/tone-options.ts):\n```typescript\nexport const TONE_OPTIONS: ToneOption[] = [\n    // How are you feeling?\n    // --> Prompt transformation -->\n    // Say rapidly and energetically: \"How-are-you-feeling?\"\n    { \n        emoji: \"🐰\", \n        name: \"Fast\",\n        transform: (text) => `Say rapidly and energetically: \"${text.split(' ').join('-')}\"`\n    },\n];\n```\n\nThen that tone is used in the [`src/components/editor/selectors/voice-popover.tsx`](src/components/editor/selectors/voice-popover.tsx) file where we make a request to Gemini 2.0 Native Audio:\n\n```typescript\nconst response = await fetch(\n    `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${process.env.NEXT_PUBLIC_GEMINI_API_KEY}`,\n    {\n        method: \"POST\",\n        headers: {\n            \"Content-Type\": \"application/json\",\n        },\n        body: JSON.stringify({\n            contents: [{\n                parts: [{ text: textToSpeak }]\n            }],\n            generationConfig: {\n                response_modalities: [\"AUDIO\"],\n                speech_config: {\n                    voice_config: {\n                        prebuilt_voice_config: {\n                            voice_name: voice\n                        }\n                    }\n                }\n            }\n        })\n    }\n);\n```\n\n\n\n\n## Try Gemini 2.0 Native Audio\n\n![Gemini 2.0 Native Audio Demo](readme/aistudio.png)\n\nYou can experiment with Gemini 2.0's in AI Studio:\n\n1. Visit [AI Studio](https://aistudio.google.com/app/)\n2. Select \"Gemini 2.0 Flash Experimental\" model\n3. Set output format to \"Audio\"\n4. Enter your prompt\n5. Click \"Generate\"\n\n\n## Credits\n\n- Built with [Novel](https://novel.sh), a Notion-style WYSIWYG editor\n- Powered by [Google's Gemini 2.0](https://blog.google/products/gemini/google-gemini-ai-collection-2024/) Native Audio\n- Code from [Trudy Painter](https://www.trudy.computer), [@trudypainter](https://github.com/trudypainter)\n- Design from [Jose Guizar](https://joseguizar.com/)\n\n## Disclaimer\n\nThis is an experiment showcasing Gemini 2.0's Native Audio capabilities, not an official Google product. We'll do our best to support and maintain this experiment but your mileage may vary.\nWe encourage open sourcing projects as a way of learning from each other. Please respect our and other creators' rights, including copyright and trademark rights when present, when sharing these works and creating derivative work. If you want more info on Google's policy, you can find that [here](https://developers.google.com/terms/site-policies).\n\n## License\n\nLicensed under the Apache-2.0 license.\n"
  },
  {
    "path": "voice-cursor/components.json",
    "content": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"default\",\n  \"rsc\": true,\n  \"tsx\": true,\n  \"tailwind\": {\n    \"config\": \"tailwind.config.ts\",\n    \"css\": \"src/app/globals.css\",\n    \"baseColor\": \"slate\",\n    \"cssVariables\": true,\n    \"prefix\": \"\"\n  },\n  \"aliases\": {\n    \"components\": \"@/components\",\n    \"utils\": \"@/lib/utils\"\n  }\n}"
  },
  {
    "path": "voice-cursor/next.config.mjs",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/** @type {import('next').NextConfig} */\nconst nextConfig = {};\n\nexport default nextConfig;\n"
  },
  {
    "path": "voice-cursor/package.json",
    "content": "{\n  \"name\": \"novel-tailwind\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next dev\",\n    \"build\": \"next build\",\n    \"start\": \"next start\",\n    \"lint\": \"next lint\"\n  },\n  \"dependencies\": {\n    \"@radix-ui/react-dropdown-menu\": \"^2.0.6\",\n    \"@radix-ui/react-popover\": \"^1.0.7\",\n    \"@radix-ui/react-separator\": \"^1.0.3\",\n    \"@radix-ui/react-slot\": \"^1.0.2\",\n    \"@radix-ui/react-tooltip\": \"^1.1.6\",\n    \"@tailwindcss/typography\": \"^0.5.10\",\n    \"class-variance-authority\": \"^0.7.0\",\n    \"clsx\": \"^2.1.0\",\n    \"lucide-react\": \"^0.363.0\",\n    \"next\": \"14.1.4\",\n    \"next-themes\": \"^0.3.0\",\n    \"novel\": \"^0.2.13\",\n    \"react\": \"^18\",\n    \"react-dom\": \"^18\",\n    \"sonner\": \"^1.4.41\",\n    \"tailwind-merge\": \"^2.2.2\",\n    \"tailwindcss-animate\": \"^1.0.7\",\n    \"uuid\": \"^11.0.3\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^20\",\n    \"@types/react\": \"^18\",\n    \"@types/react-dom\": \"^18\",\n    \"@types/uuid\": \"^10.0.0\",\n    \"autoprefixer\": \"^10.0.1\",\n    \"postcss\": \"^8\",\n    \"tailwindcss\": \"^3.3.0\",\n    \"typescript\": \"^5\"\n  }\n}\n"
  },
  {
    "path": "voice-cursor/postcss.config.js",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nmodule.exports = {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n};\n"
  },
  {
    "path": "voice-cursor/src/app/default-value.ts",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { getVoiceColor } from \"@/lib/voice-options\";\n\nexport const defaultValue = {\n  type: \"doc\",\n  content: [\n    {\n      type: \"heading\",\n      attrs: { level: 1 },\n      content: [{ type: \"text\", text: \"✨ Voice Cursor\" }]\n    },\n    {\n      type: \"paragraph\",\n      content: [\n        { type: \"text\", text: \"👋 Hello! This is a starter demo using native audio in Gemini 2.0. Just write text below, then highlight it to hear it spoken in different ways.\" }\n      ]\n    },\n    {\n      type: \"paragraph\",\n      content: [\n        {\n          type: \"text\",\n          text: \"You can hear things read verrrrry mysteriously.\",\n          marks: [\n            {\n              type: \"highlight\",\n              attrs: {\n                audioKey: \"orus-mysterious\",\n                tone: \"mysterious\",\n                toneEmoji: \"🔮\",\n                color: getVoiceColor(\"Orus\"),\n                voice: \"Orus\",\n                prompt: \"Say this like a dramatic wizard speaking very mysteriously: \\\"You can hear things read verrrrry mysteriously.\\\"\"\n              }\n            }\n          ]\n        }\n      ]\n    },\n    {\n      type: \"paragraph\",\n      content: [\n        {\n          type: \"text\",\n          text: \"Or whispered, like a secret.\",\n          marks: [\n            {\n              type: \"highlight\",\n              attrs: {\n                audioKey: \"orus-whispered\",\n                tone: \"whispering\",\n                toneEmoji: \"🦗\",\n                color: getVoiceColor(\"Orus\"),\n                voice: \"Orus\",\n                prompt: \"Whisper in a hushed, secretive tone: \\\"or whispered, like a secret.\\\"\"\n              }\n            }\n          ]\n        }\n      ]\n    },\n    {\n      type: \"paragraph\",\n      content: [\n        {\n          type: \"text\",\n          text: \"Or spoken in … with lots … and lots … of DRAMA!\",\n          marks: [\n            {\n              type: \"highlight\",\n              attrs: {\n                audioKey: \"orus-dramatic\",\n                tone: \"dramatic\",\n                toneEmoji: \"🎭\",\n                color: getVoiceColor(\"Orus\"),\n                voice: \"Orus\",\n                prompt: \"Say this like a Shakespearean actor speaking a very dramatic monologue: \\\"Or spoken in … with lots … and lots … of DRAMA!\\\"\"\n              }\n            }\n          ]\n        }\n      ]\n    },\n    {\n      type: \"heading\",\n      attrs: { level: 2 },\n      content: [{ type: \"text\", text: \"Examples\" }]\n    },\n    {\n      type: \"paragraph\",\n      content: [\n        {\n          type: \"text\",\n          text: \"\\\"Trudy, my friend, I must tell you about some ancient mysteries.\\\"\",\n          marks: [\n            {\n              type: \"highlight\",\n              attrs: {\n                audioKey: \"charon-mysterious\",\n                tone: \"mysterious\",\n                toneEmoji: \"🔮\",\n                color: getVoiceColor(\"Charon\"),\n                voice: \"Charon\",\n                prompt: \"Say this like a dramatic wizard speaking very mysteriously: \\\"Trudy, my friend, I must tell you about some ancient mysteries.\\\"\"\n              }\n            }\n          ]\n        },\n        { type: \"text\", text: \" Alex said.\" }\n      ]\n    },\n    {\n      type: \"paragraph\",\n      content: [\n        {\n          type: \"text\",\n          text: \"\\\"Oh hey Alex, what's going on?\\\"\",\n          marks: [\n            {\n              type: \"highlight\",\n              attrs: {\n                audioKey: \"kore-neutral\",\n                tone: \"casual\",\n                toneEmoji: \"💬\",\n                color: getVoiceColor(\"Kore\"),\n                voice: \"Kore\",\n                prompt: \"Say: \\\"Oh hey Alex, what's going on?\\\"\"\n              }\n            }\n          ]\n        },\n        { type: \"text\", text: \" Trudy asked.\" }\n      ]\n    },\n    {\n      type: \"paragraph\",\n      content: [\n        {\n          type: \"text\",\n          text: \"\\\"Mysterious, mysteries, oh my gosh I love mysteries!!!\\\"\",\n          marks: [\n            {\n              type: \"highlight\",\n              attrs: {\n                audioKey: \"zephyr-excited\",\n                tone: \"excited\",\n                toneEmoji: \"😃\",\n                color: getVoiceColor(\"Zephyr\"),\n                voice: \"Zephyr\",\n                prompt: \"Say this like a very excited person: \\\"Mysterious, mysteries, oh my gosh I love mysteries!!!\\\"\"\n              }\n            }\n          ]\n        },\n        { type: \"text\", text: \" Jordan exclaimed, running towards the group excitedly.\" }\n      ]\n    },\n    {\n      type: \"paragraph\",\n      content: [\n        {\n          type: \"text\",\n          text: \"\\\"Woah.. can everyone, just chill …\\\"\",\n          marks: [\n            {\n              type: \"highlight\",\n              attrs: {\n                audioKey: \"orus-surfer\",\n                tone: \"surfer\",\n                toneEmoji: \"🏄\",\n                color: getVoiceColor(\"Orus\"),\n                voice: \"Orus\",\n                prompt: \"Say this like a chill surfer: \\\"Woah.. can everyone, just chill …\\\"\"\n              }\n            }\n          ]\n        },\n        { type: \"text\", text: \" said Dan.\" }\n      ]\n    },\n    {\n      type: \"paragraph\",\n      content: [\n        {\n          type: \"text\",\n          text: \"\\\"Can you make this quick. I gotta run in like 2 minutes\\\"\",\n          marks: [\n            {\n              type: \"highlight\",\n              attrs: {\n                audioKey: \"leda-fast\",\n                tone: \"fast\",\n                toneEmoji: \"🐰\",\n                color: getVoiceColor(\"Leda\"),\n                voice: \"Leda\",\n                prompt: \"Say this like a fast person: \\\"Can you make this quick. I gotta run in like 2 minutes\\\"\"\n              }\n            }\n          ]\n        },\n        { type: \"text\", text: \" Suz said.\" }\n      ]\n    },\n    {\n      type: \"heading\",\n      attrs: { level: 2 },\n      content: [{ type: \"text\", text: \"Give it a try\" }]\n    },\n    {\n      type: \"paragraph\",\n      content: [\n        { type: \"text\", text: \"Press '/' for commands\" }\n      ]\n    }\n  ]\n};\n"
  },
  {
    "path": "voice-cursor/src/app/globals.css",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer base {\n  :root {\n    --background: 0 0% 100%;\n    --foreground: 222.2 84% 4.9%;\n\n    --card: 0 0% 100%;\n    --card-foreground: 222.2 84% 4.9%;\n\n    --popover: 0 0% 100%;\n    --popover-foreground: 222.2 84% 4.9%;\n\n    --primary: 222.2 47.4% 11.2%;\n    --primary-foreground: 210 40% 98%;\n\n    --secondary: 210 40% 96.1%;\n    --secondary-foreground: 222.2 47.4% 11.2%;\n\n    --muted: 210 40% 96.1%;\n    --muted-foreground: 215.4 16.3% 46.9%;\n\n    --accent: 210 40% 96.1%;\n    --accent-foreground: 222.2 47.4% 11.2%;\n\n    --destructive: 0 84.2% 60.2%;\n    --destructive-foreground: 210 40% 98%;\n\n    --border: 214.3 31.8% 91.4%;\n    --input: 214.3 31.8% 91.4%;\n    --ring: 222.2 84% 4.9%;\n\n    --radius: 0.5rem;\n\n    /* Novel highlight colors */\n    --novel-highlight-default: #ffffff;\n    --novel-highlight-purple: rgba(147, 51, 234, 0.2);\n    --novel-highlight-red: rgba(224, 0, 0, 0.2);\n    --novel-highlight-yellow: rgba(234, 179, 8, 0.2);\n    --novel-highlight-blue: rgba(37, 99, 235, 0.2);\n    --novel-highlight-green: rgba(0, 138, 0, 0.2);\n    --novel-highlight-orange: rgba(255, 165, 0, 0.2);\n    --novel-highlight-pink: rgba(186, 64, 129, 0.2);\n    --novel-highlight-gray: rgba(168, 162, 158, 0.2);\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border;\n  }\n  body {\n    @apply bg-background text-foreground;\n  }\n}\n\n/* Tippy tooltip styles */\n.tippy-box {\n  @apply bg-white border border-gray-200 shadow-lg rounded-md !important;\n}\n\n.tippy-box[data-placement^='bottom'] > .tippy-arrow:before {\n  @apply border-b-gray-200 !important;\n}\n\n/* Audio highlight styles */\n.audio-highlight-text {\n  position: relative;\n  cursor: pointer;\n  padding: 4px 8px;\n  border-radius: 8px;\n  margin: 0px -4px;\n}\n\nmark {\n  border-radius: 8px !important;\n  padding: 8px 0px !important;\n}\nmark:hover {\n  cursor: pointer !important;\n}\n\n/* Add back the emoji styles */\n.audio-highlight-text::before {\n  content: attr(data-tone-emoji);\n  position: absolute;\n  top: -20px;\n  left: 6px;\n  font-size: 14px;\n  padding: 4px;\n  border-top-left-radius: 4px;\n  border-top-right-radius: 4px;\n  line-height: 1;\n  font-size: 18px;\n}"
  },
  {
    "path": "voice-cursor/src/app/layout.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { Metadata } from \"next\";\nimport { Inter as FontSans } from \"next/font/google\";\nimport \"./prosemirror.css\";\nimport \"./globals.css\";\nimport { cn } from \"@/lib/utils\";\nimport { ThemeProvider } from \"@/components/theme-provider\";\nimport { Toaster } from \"@/components/ui/sonner\";\nconst fontSans = FontSans({\n  subsets: [\"latin\"],\n  variable: \"--font-sans\",\n});\n\nexport const metadata: Metadata = {\n  title: \"Voice Cursor\",\n  description: \"Powered by Gemini 2.0 Native Audio\",\n};\n\nexport default function RootLayout({\n  children,\n}: Readonly<{\n  children: React.ReactNode;\n}>) {\n  return (\n    <html lang=\"en\">\n      <body\n        className={cn(\n          \"min-h-screen bg-background font-sans antialiased\",\n          fontSans.variable,\n        )}\n      >\n        <ThemeProvider\n          attribute=\"class\"\n          defaultTheme=\"system\"\n          enableSystem\n          disableTransitionOnChange\n        >\n          {children}\n          <Toaster />\n        </ThemeProvider>\n      </body>\n    </html>\n  );\n}\n"
  },
  {
    "path": "voice-cursor/src/app/page.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\"use client\";\n\nimport dynamic from \"next/dynamic\";\nimport type { JSONContent } from \"novel\";\nimport { useState, useEffect } from \"react\";\nimport { defaultValue } from \"./default-value\";\n\nconst EditorWrapper = dynamic(() => import(\"@/components/editor/client-editor\"), {\n  ssr: false\n});\n\n// Helper function to extract audio keys from the default value\nconst extractAudioKeys = (content: any[]): string[] => {\n  const audioKeys: string[] = [];\n  \n  const traverse = (node: any) => {\n    if (node.content) {\n      node.content.forEach(traverse);\n    }\n    \n    if (node.marks) {\n      node.marks.forEach((mark: any) => {\n        if (mark.type === 'highlight' && mark.attrs.audioKey) {\n          audioKeys.push(mark.attrs.audioKey);\n        }\n      });\n    }\n  };\n\n  content.forEach(traverse);\n  return Array.from(new Set(audioKeys)); // Remove duplicates\n};\n\nexport default function Home() {\n  const [value, setValue] = useState<JSONContent>(defaultValue);\n  const [initializationComplete, setInitializationComplete] = useState(false);\n\n  // Initialize the audio blobs for all highlighted text\n  useEffect(() => {\n    let mounted = true;\n    console.log('🎵 Starting to initialize audio files...');\n\n    const initializeDefaultAudios = async () => {\n      if (typeof window === 'undefined') return;\n\n      // Wait for EditorContext to be available\n      if (!(window as any).editorContext) {\n        console.log('Waiting for EditorContext...');\n        await new Promise(resolve => setTimeout(resolve, 1000));\n      }\n\n      if (!(window as any).editorContext) {\n        console.error('EditorContext not available after waiting');\n        return;\n      }\n\n      console.log('EditorContext available, loading audio files...');\n\n      // Extract audio keys from default value\n      const audioKeys = extractAudioKeys(defaultValue.content);\n      console.log('Found audio keys:', audioKeys);\n\n      for (const audioKey of audioKeys) {\n        try {\n          const filename = `${audioKey}.wav`;\n          console.log(`Loading audio file: ${filename}`);\n\n          // Fetch the audio file\n          const response = await fetch(`/audio/${filename}`);\n          if (!response.ok) {\n            throw new Error(`Failed to load audio file: ${filename}`);\n          }\n\n          // Get the audio data as a blob\n          const audioBlob = await response.blob();\n          console.log(`Successfully loaded ${filename}:`, {\n            size: audioBlob.size,\n            type: audioBlob.type\n          });\n\n          // Store in EditorContext\n          (window as any).editorContext.setAudioBlob(audioKey, audioBlob);\n          console.log(`Stored audio blob for ${audioKey}`);\n\n          // Test that we can access it\n          const storedBlob = (window as any).editorContext.audioBlobs.get(audioKey);\n          console.log(`Verification - Retrieved blob for ${audioKey}:`, {\n            exists: Boolean(storedBlob),\n            size: storedBlob?.size,\n            type: storedBlob?.type\n          });\n\n        } catch (error) {\n          console.error(`Error loading audio for ${audioKey}:`, error);\n        }\n      }\n\n      if (mounted) {\n        setInitializationComplete(true);\n      }\n    };\n\n    initializeDefaultAudios();\n\n    return () => {\n      mounted = false;\n    };\n  }, []);\n\n  // Log when initialization is complete\n  useEffect(() => {\n    if (initializationComplete) {\n      console.log('🎵 Audio initialization complete. Available audio:',\n        Array.from((window as any).editorContext?.audioBlobs?.keys() || []));\n    }\n  }, [initializationComplete]);\n\n  return (\n    <main className=\"flex min-h-screen flex-col items-center justify-between p-24 bg-white\">\n      <EditorWrapper initialValue={value} onChange={setValue} />\n      <div className=\"fixed bottom-0 left-0 w-fit text-left bg-transparent p-4 text-black flex justify-between items-center\">\n        <p className=\"text-sm text-muted-foreground text-left\">Powered by \n          <br /> <a href=\"https://ai.google.dev\" target=\"_blank\" rel=\"noopener noreferrer\" className=\"text-muted-foreground underline\">Gemini 2.0 Native Audio</a></p>\n      </div>\n    </main>\n  );\n}\n"
  },
  {
    "path": "voice-cursor/src/app/prosemirror.css",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n.ProseMirror .is-editor-empty:first-child::before {\n  content: attr(data-placeholder);\n  float: left;\n  color: hsl(var(--muted-foreground));\n  pointer-events: none;\n  height: 0;\n}\n.ProseMirror .is-empty::before {\n  content: attr(data-placeholder);\n  float: left;\n  color: hsl(var(--muted-foreground));\n  pointer-events: none;\n  height: 0;\n}\n\n/* Custom image styles */\n\n.ProseMirror img {\n  transition: filter 0.1s ease-in-out;\n\n  &:hover {\n    cursor: pointer;\n    filter: brightness(90%);\n  }\n\n  &.ProseMirror-selectednode {\n    outline: 3px solid #5abbf7;\n    filter: brightness(90%);\n  }\n}\n\n.img-placeholder {\n  position: relative;\n\n  &:before {\n    content: \"\";\n    box-sizing: border-box;\n    position: absolute;\n    top: 50%;\n    left: 50%;\n    width: 36px;\n    height: 36px;\n    border-radius: 50%;\n    border: 3px solid var(--novel-stone-200);\n    border-top-color: var(--novel-stone-800);\n    animation: spinning 0.6s linear infinite;\n  }\n}\n\n@keyframes spinning {\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n/* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */\n\nul[data-type=\"taskList\"] li > label {\n  margin-right: 0.2rem;\n  user-select: none;\n}\n\n@media screen and (max-width: 768px) {\n  ul[data-type=\"taskList\"] li > label {\n    margin-right: 0.5rem;\n  }\n}\n\nul[data-type=\"taskList\"] li > label input[type=\"checkbox\"] {\n  -webkit-appearance: none;\n  appearance: none;\n  background-color: hsl(var(--background));\n  margin: 0;\n  cursor: pointer;\n  width: 1.2em;\n  height: 1.2em;\n  position: relative;\n  top: 5px;\n  border: 2px solid hsl(var(--border));\n  margin-right: 0.3rem;\n  display: grid;\n  place-content: center;\n\n  &:hover {\n    background-color: hsl(var(--accent));\n  }\n\n  &:active {\n    background-color: hsl(var(--accent));\n  }\n\n  &::before {\n    content: \"\";\n    width: 0.65em;\n    height: 0.65em;\n    transform: scale(0);\n    transition: 120ms transform ease-in-out;\n    box-shadow: inset 1em 1em;\n    transform-origin: center;\n    clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);\n  }\n\n  &:checked::before {\n    transform: scale(1);\n  }\n}\n\nul[data-type=\"taskList\"] li[data-checked=\"true\"] > div > p {\n  color: var(--muted-foreground);\n  text-decoration: line-through;\n  text-decoration-thickness: 2px;\n}\n\n/* Overwrite tippy-box original max-width */\n\n.tippy-box {\n  max-width: 400px !important;\n}\n\n.ProseMirror:not(.dragging) .ProseMirror-selectednode {\n  outline: none !important;\n  background-color: var(--novel-highlight-blue);\n  transition: background-color 0.2s;\n  box-shadow: none;\n}\n\n.drag-handle {\n  position: fixed;\n  opacity: 1;\n  transition: opacity ease-in 0.2s;\n  border-radius: 0.25rem;\n\n  background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(0, 0, 0, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E\");\n  background-size: calc(0.5em + 0.375rem) calc(0.5em + 0.375rem);\n  background-repeat: no-repeat;\n  background-position: center;\n  width: 1.2rem;\n  height: 1.5rem;\n  z-index: 50;\n  cursor: grab;\n\n  &:hover {\n    background-color: var(--novel-stone-100);\n    transition: background-color 0.2s;\n  }\n\n  &:active {\n    background-color: var(--novel-stone-200);\n    transition: background-color 0.2s;\n    cursor: grabbing;\n  }\n\n  &.hide {\n    opacity: 0;\n    pointer-events: none;\n  }\n\n  @media screen and (max-width: 600px) {\n    display: none;\n    pointer-events: none;\n  }\n}\n\n.dark .drag-handle {\n  background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(255, 255, 255, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E\");\n}\n"
  },
  {
    "path": "voice-cursor/src/app/test-audio/page.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\"use client\";\n\nimport { useState, useEffect } from \"react\";\n\n// Voice options based on the API documentation\nconst VOICE_OPTIONS = {\n  \"Named Voices\": [\"Zephyr\", \"Puck\", \"Charon\", \"Kore\", \"Fenrir\", \"Leda\", \"Orus\", \"Gemini H\"]\n};\n\nexport default function TestAudio() {\n  const [input, setInput] = useState('Say in a cheerful tone: \"Hello world, how are you today?\"');\n  const [output, setOutput] = useState(\"\");\n  const [isLoading, setIsLoading] = useState(false);\n  const [selectedVoice, setSelectedVoice] = useState(\"Kore\");\n  const [audioUrl, setAudioUrl] = useState<string | null>(null);\n  const [errorDetails, setErrorDetails] = useState<string>(\"\");\n\n  const handleSubmit = async () => {\n    setIsLoading(true);\n    setErrorDetails(\"\");\n    try {\n      const response = await fetch(\n        `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${process.env.NEXT_PUBLIC_GEMINI_API_KEY}`,\n        {\n          method: \"POST\",\n          headers: {\n            \"Content-Type\": \"application/json\",\n          },\n          body: JSON.stringify({\n            contents: [{\n              parts: [{ text: input }]\n            }],\n            generationConfig: {\n              response_modalities: [\"AUDIO\"],\n              speech_config: {\n                voice_config: {\n                  prebuilt_voice_config: {\n                    voice_name: selectedVoice\n                  }\n                }\n              }\n            }\n          })\n        }\n      );\n\n      const data = await response.json();\n      console.log(\"API Response:\", data);\n\n      if (!data.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data) {\n        throw new Error('No audio data received in response');\n      }\n\n      const base64Audio = data.candidates[0].content.parts[0].inlineData.data;\n      console.log(\"Base64 Audio:\", base64Audio);\n\n      // Parse the audio format parameters\n      const mimeType = data.candidates[0].content.parts[0].inlineData.mimeType || 'audio/wav';\n      const mimeParams = mimeType.split(';').reduce((acc: Record<string, string>, param: string) => {\n        const [key, value] = param.split('=');\n        if (value) {\n          acc[key.trim()] = value.trim();\n        } else {\n          acc.mimeBase = key.trim();\n        }\n        return acc;\n      }, {} as Record<string, string>);\n\n      console.log(\"MIME params:\", mimeParams);\n\n      // Convert base64 to PCM audio data\n      const byteCharacters = atob(base64Audio);\n      const byteNumbers = new Array(byteCharacters.length);\n      for (let i = 0; i < byteCharacters.length; i++) {\n        byteNumbers[i] = byteCharacters.charCodeAt(i);\n      }\n      const byteArray = new Uint8Array(byteNumbers);\n\n      // Create WAV header for PCM data\n      const wavHeader = new ArrayBuffer(44);\n      const view = new DataView(wavHeader);\n\n      // \"RIFF\" chunk descriptor\n      view.setUint32(0, 0x52494646, false); // \"RIFF\"\n      view.setUint32(4, 36 + byteArray.length, true); // file length\n      view.setUint32(8, 0x57415645, false); // \"WAVE\"\n\n      // \"fmt \" sub-chunk\n      view.setUint32(12, 0x666D7420, false); // \"fmt \"\n      view.setUint32(16, 16, true); // subchunk size\n      view.setUint16(20, 1, true); // PCM audio format\n      view.setUint16(22, 1, true); // Mono channel\n      view.setUint32(24, parseInt(mimeParams.rate) || 24000, true); // sample rate\n      view.setUint32(28, (parseInt(mimeParams.rate) || 24000) * 2, true); // byte rate\n      view.setUint16(32, 2, true); // block align\n      view.setUint16(34, 16, true); // bits per sample\n\n      // \"data\" sub-chunk\n      view.setUint32(36, 0x64617461, false); // \"data\"\n      view.setUint32(40, byteArray.length, true); // data length\n\n      // Combine header and PCM data without spread operator\n      const wavBytes = new Uint8Array(wavHeader.byteLength + byteArray.length);\n      wavBytes.set(new Uint8Array(wavHeader), 0);\n      wavBytes.set(byteArray, wavHeader.byteLength);\n\n      // Create blob with WAV format\n      const audioBlob = new Blob([wavBytes], { type: 'audio/wav' });\n\n      // Create URL from blob\n      const audioUrl = URL.createObjectURL(audioBlob);\n      setAudioUrl(audioUrl);\n      setOutput(\"Audio generated successfully!\");\n\n    } catch (error) {\n      console.error(\"Error:\", error);\n      setErrorDetails(error instanceof Error ? error.message : 'An error occurred');\n      setOutput(\"Failed to generate audio response.\");\n    } finally {\n      setIsLoading(false);\n    }\n  };\n\n  // Clean up object URL when component unmounts or when audioUrl changes\n  useEffect(() => {\n    return () => {\n      if (audioUrl) {\n        URL.revokeObjectURL(audioUrl);\n      }\n    };\n  }, [audioUrl]);\n\n  return (\n    <main className=\"flex min-h-screen flex-col items-center justify-between p-24\">\n      <div className=\"flex flex-col p-6 border max-w-xl w-full gap-6 rounded-md bg-card\">\n        <div className=\"flex justify-between\">\n          <h1 className=\"text-4xl font-semibold\">Audio Test Page</h1>\n        </div>\n\n        <div className=\"flex flex-col gap-4\">\n          <div className=\"flex flex-col gap-2\">\n            <label htmlFor=\"voice-select\" className=\"font-medium\">\n              Select Voice\n            </label>\n            <select\n              id=\"voice-select\"\n              className=\"w-full p-2 border rounded-md bg-background\"\n              value={selectedVoice}\n              onChange={(e) => setSelectedVoice(e.target.value)}\n            >\n              {Object.entries(VOICE_OPTIONS).map(([category, voices]) => (\n                <optgroup key={category} label={category}>\n                  {voices.map(voice => (\n                    <option key={voice} value={voice}>\n                      {voice}\n                    </option>\n                  ))}\n                </optgroup>\n              ))}\n            </select>\n          </div>\n\n          <textarea\n            className=\"w-full p-2 border rounded-md bg-background\"\n            rows={4}\n            value={input}\n            onChange={(e) => setInput(e.target.value)}\n            placeholder=\"Enter your text here...\"\n          />\n\n          <button\n            className=\"px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50\"\n            onClick={handleSubmit}\n            disabled={isLoading || !input.trim()}\n          >\n            {isLoading ? \"Processing...\" : \"Generate Response\"}\n          </button>\n\n          {errorDetails && (\n            <div className=\"mt-4 p-4 border border-red-500 rounded-md bg-red-50 text-red-700\">\n              <h3 className=\"font-semibold\">Error Details:</h3>\n              <p>{errorDetails}</p>\n            </div>\n          )}\n\n          {audioUrl && (\n            <div className=\"mt-4\">\n              <h2 className=\"text-xl font-semibold mb-2\">Audio Response:</h2>\n              <audio controls className=\"w-full\" src={audioUrl}>\n                Your browser does not support the audio element.\n              </audio>\n            </div>\n          )}\n\n          {output && (\n            <div className=\"mt-4\">\n              <div className=\"p-4 border rounded-md bg-background\">\n                <pre className=\"whitespace-pre-wrap\">{output}</pre>\n              </div>\n            </div>\n          )}\n        </div>\n      </div>\n\n    </main>\n  );\n} "
  },
  {
    "path": "voice-cursor/src/components/editor/advanced-editor.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\"use client\";\nimport React, { useEffect, useState } from \"react\";\nimport {\n  EditorRoot,\n  EditorCommand,\n  EditorCommandItem,\n  EditorCommandEmpty,\n  EditorContent,\n  type JSONContent,\n  EditorCommandList,\n  EditorBubble,\n} from \"novel\";\nimport { ImageResizer, handleCommandNavigation } from \"novel/extensions\";\nimport { defaultExtensions } from \"./extensions\";\nimport { slashCommand, suggestionItems } from \"./slash-command\";\nimport { handleImageDrop, handleImagePaste } from \"novel/plugins\";\nimport { uploadFn } from \"./image-upload\";\nimport { VoicePopover } from \"./selectors/voice-popover\";\nimport { AudioHighlight } from \"./extensions/audio-highlight\";\n\nconst extensions = [...defaultExtensions, slashCommand, AudioHighlight];\n\ninterface EditorProp {\n  initialValue?: JSONContent;\n  onChange: (value: JSONContent) => void;\n}\n\n// Create a context for audio blobs\nexport const AudioBlobContext = React.createContext<{\n  audioBlobs: Map<string, Blob>;\n  setAudioBlob: (key: string, blob: Blob) => void;\n}>({\n  audioBlobs: new Map(),\n  setAudioBlob: () => {},\n});\n\nconst Editor = ({ initialValue, onChange }: EditorProp) => {\n  const [showBubbleMenu, setShowBubbleMenu] = useState(false);\n  const [audioBlobs, setAudioBlobs] = useState<Map<string, Blob>>(() => {\n    // Try to restore audio blobs from storage on initial load\n    const storedBlobs = localStorage.getItem('audioBlobs');\n    if (storedBlobs) {\n      try {\n        const parsed = JSON.parse(storedBlobs);\n        return new Map(Object.entries(parsed).map(([key, value]) => {\n          return [key, new Blob([value as BlobPart], { type: 'audio/wav' })];\n        }));\n      } catch (e) {\n        console.error('Failed to restore audio blobs:', e);\n        return new Map();\n      }\n    }\n    return new Map();\n  });\n\n  const setAudioBlob = (key: string, blob: Blob) => {\n    setAudioBlobs(prev => {\n      const newMap = new Map(prev);\n      newMap.set(key, blob);\n      \n      // Store updated blobs in localStorage\n      const blobsToStore = Object.fromEntries(newMap);\n      localStorage.setItem('audioBlobs', JSON.stringify(blobsToStore));\n      \n      return newMap;\n    });\n  };\n\n  // Expose context to window for audio highlight extension\n  useEffect(() => {\n    (window as any).editorContext = { audioBlobs, setAudioBlob };\n    return () => {\n      delete (window as any).editorContext;\n    };\n  }, [audioBlobs]);\n\n  return (\n    <AudioBlobContext.Provider value={{ audioBlobs, setAudioBlob }}>\n      <EditorRoot>\n        <EditorContent\n          className=\" p-20 rounded-xl w-[800px] bg-white \"\n          {...(initialValue && { initialContent: initialValue })}\n          extensions={extensions}\n          editorProps={{\n            handleDOMEvents: {\n              keydown: (_view, event) => handleCommandNavigation(event),\n            },\n            handlePaste: (view, event) => handleImagePaste(view, event, uploadFn),\n            handleDrop: (view, event, _slice, moved) =>\n              handleImageDrop(view, event, moved, uploadFn),\n            attributes: {\n              class: `prose prose-lg prose-headings:font-title font-default focus:outline-none max-w-full`,\n            },\n          }}\n          onUpdate={({ editor }) => {\n            // Show bubble menu when text is selected\n            const selection = editor.state.selection;\n            const hasSelection = !selection.empty;\n            setShowBubbleMenu(hasSelection);\n            \n            onChange(editor.getJSON());\n          }}\n          slotAfter={<ImageResizer />}\n        >\n          <EditorCommand className=\"z-50 h-auto max-h-[330px] overflow-y-auto rounded-md border border-muted bg-background px-1 py-2 shadow-md transition-all\">\n            <EditorCommandEmpty className=\"px-2 text-muted-foreground\">\n              No results\n            </EditorCommandEmpty>\n            <EditorCommandList>\n              {suggestionItems.map((item) => (\n                <EditorCommandItem\n                  value={item.title}\n                  onCommand={(val) => item.command?.(val)}\n                  className={`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm hover:bg-accent aria-selected:bg-accent `}\n                  key={item.title}\n                >\n                  <div className=\"flex h-10 w-10 items-center justify-center rounded-md border border-muted bg-background\">\n                    {item.icon}\n                  </div>\n                  <div>\n                    <p className=\"font-medium\">{item.title}</p>\n                    <p className=\"text-xs text-muted-foreground\">\n                      {item.description}\n                    </p>\n                  </div>\n                </EditorCommandItem>\n              ))}\n            </EditorCommandList>\n          </EditorCommand>\n\n          <EditorBubble\n            tippyOptions={{\n              placement: \"top\",\n              maxWidth: \"none\",\n              duration: 200,\n            }}\n            className=\"w-fit overflow-visible rounded-md border border-muted bg-background shadow-xl transition-all\"\n          >\n            {/* The VoicePopover is the only thing that should be in the bubble menu */}\n            <VoicePopover onGenerateComplete={() => setShowBubbleMenu(false)} />\n          </EditorBubble>\n        </EditorContent>\n      </EditorRoot>\n    </AudioBlobContext.Provider>\n  );\n};\n\nexport default Editor;\n"
  },
  {
    "path": "voice-cursor/src/components/editor/audio-menu.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { useEffect, useRef, useState } from 'react';\nimport { Play, Download, Loader2 } from 'lucide-react';\nimport Image from 'next/image';\nimport { cn } from '@/lib/utils';\nimport { PromptPopover } from './prompt-popover';\n\ninterface AudioMenuProps {\n  audioKey: string;\n  position: { top: number; left: number };\n  prompt: string;\n  onClose: () => void;\n}\n\nexport function AudioMenu({ audioKey, position, prompt, onClose }: AudioMenuProps) {\n  const [isPlaying, setIsPlaying] = useState(false);\n  const [isLoading, setIsLoading] = useState(false);\n  const menuRef = useRef<HTMLDivElement>(null);\n  const [showPrompt, setShowPrompt] = useState(false);\n  const [isPersistent, setIsPersistent] = useState(false);\n  const promptRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    function handleClickOutside(event: MouseEvent) {\n      if (menuRef.current && \n          !menuRef.current.contains(event.target as Node) && \n          !promptRef.current?.contains(event.target as Node)) {\n        onClose();\n      }\n    }\n\n    document.addEventListener('mousedown', handleClickOutside);\n    return () => document.removeEventListener('mousedown', handleClickOutside);\n  }, [onClose]);\n\n  useEffect(() => {\n    if (!isPersistent) return;\n\n    function handleClickOutside(event: MouseEvent) {\n      if (promptRef.current && \n          !promptRef.current.contains(event.target as Node) &&\n          !menuRef.current?.contains(event.target as Node)) {\n        setShowPrompt(false);\n        setIsPersistent(false);\n      }\n    }\n\n    document.addEventListener('mousedown', handleClickOutside);\n    return () => document.removeEventListener('mousedown', handleClickOutside);\n  }, [isPersistent]);\n\n  const handlePlay = async () => {\n    const audioBlob = (window as any).editorContext?.audioBlobs?.get(audioKey);\n    if (!audioBlob) {\n      console.error(\"❌ No audio found for key:\", audioKey);\n      return;\n    }\n\n    try {\n      setIsLoading(true);\n      const url = URL.createObjectURL(audioBlob);\n      const audio = new Audio();\n\n      audio.oncanplaythrough = () => {\n        setIsLoading(false);\n        audio.play().catch(error => {\n          console.error(\"Play error:\", error);\n          setIsPlaying(false);\n        });\n        setIsPlaying(true);\n      };\n\n      audio.onended = () => {\n        setIsPlaying(false);\n        URL.revokeObjectURL(url);\n      };\n\n      audio.onerror = (e) => {\n        console.error(\"❌ Audio error:\", e);\n        setIsPlaying(false);\n        setIsLoading(false);\n        URL.revokeObjectURL(url);\n      };\n\n      audio.src = url;\n      audio.load();\n\n    } catch (error) {\n      console.error(\"❌ Error setting up audio:\", error);\n      setIsPlaying(false);\n      setIsLoading(false);\n    }\n  };\n\n  const handleDownload = () => {\n    const audioBlob = (window as any).editorContext?.audioBlobs?.get(audioKey);\n    if (audioBlob) {\n      const url = URL.createObjectURL(audioBlob);\n      const a = document.createElement('a');\n      a.href = url;\n      a.download = `audio-${audioKey}.wav`;\n      document.body.appendChild(a);\n      a.click();\n      document.body.removeChild(a);\n      URL.revokeObjectURL(url);\n    }\n  };\n\n  const handleInfoMouseEnter = () => {\n    if (!isPersistent) {\n      setShowPrompt(true);\n    }\n  };\n\n  const handleInfoMouseLeave = () => {\n    if (!isPersistent) {\n      setShowPrompt(false);\n    }\n  };\n\n  const handleInfoClick = () => {\n    setShowPrompt(true);\n    setIsPersistent(true);\n  };\n\n  const handlePromptClose = () => {\n    setShowPrompt(false);\n    setIsPersistent(false);\n  };\n\n  return (\n    <>\n      <div\n        ref={menuRef}\n        className=\"absolute bg-white rounded-lg shadow-sm border border-gray-200 p-1.5 z-50\"\n        style={{\n          top: `${position.top}px`,\n          left: `${position.left}px`,\n        }}\n      >\n        <div className=\"flex items-center gap-1 relative\">\n          <button\n            onClick={handlePlay}\n            className=\"relative p-1 rounded hover:bg-gray-100 transition-colors mr-3\"\n            disabled={isLoading}\n          >\n            {isPlaying ? (\n              <Loader2 className=\"w-3.5 h-3.5 text-gray-500 animate-spin\" />\n            ) : (\n              <Play className=\"w-3.5 h-3.5 text-gray-500\" />\n            )}\n          </button>\n          \n          <div className=\"absolute top-0.5 left-7 h-9 border-l border-gray-200 -translate-y-2 bg-gray-200 mx-0.5\">\n          </div>\n          \n          <button\n            onClick={handleDownload}\n            className=\"p-1 rounded hover:bg-gray-100 transition-colors\"\n          >\n            <Download className=\"w-3.5 h-3.5 text-gray-500\" />\n          </button>\n          \n          <button\n            onClick={handleInfoClick}\n            onMouseEnter={handleInfoMouseEnter}\n            onMouseLeave={handleInfoMouseLeave}\n            className={cn(\n              \"p-1 rounded transition-colors\",\n              isPersistent ? \"bg-gray-100\" : \"hover:bg-gray-100\"\n            )}\n          >\n            <Image \n              src=\"/info_spark.svg\"\n              alt=\"Info\"\n              width={16}\n              height={16}\n              className=\"text-gray-500\"\n            />\n          </button>\n        </div>\n      </div>\n      \n      {showPrompt && (\n        <div ref={promptRef}>\n          <PromptPopover\n            position={{\n              top: position.top + 10,\n              left: position.left - 20,\n            }}\n            prompt={prompt || \"No prompt available\"}\n            onClose={handlePromptClose}\n          />\n        </div>\n      )}\n    </>\n  );\n} "
  },
  {
    "path": "voice-cursor/src/components/editor/client-editor.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport Editor from \"./advanced-editor\";\nimport type { JSONContent } from \"novel\";\n\ninterface EditorWrapperProps {\n  initialValue: JSONContent;\n  onChange: (value: JSONContent) => void;\n}\n\nexport default function EditorWrapper({ initialValue, onChange }: EditorWrapperProps) {\n  return <Editor initialValue={initialValue} onChange={onChange} />;\n} "
  },
  {
    "path": "voice-cursor/src/components/editor/extensions/audio-highlight.ts",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Extension } from \"@tiptap/core\";\nimport { Plugin, PluginKey } from \"@tiptap/pm/state\";\nimport { Decoration, DecorationSet } from \"@tiptap/pm/view\";\nimport { Mark } from \"@tiptap/pm/model\";\nimport { v4 as uuidv4 } from 'uuid';\nimport * as React from 'react';\nimport { createRoot, type Root } from 'react-dom/client';\nimport { AudioMenu } from '../audio-menu';\n\ntype MouseLeaveHandler = EventListener;\n\n// Keep track of active play buttons to prevent duplicates\nconst activePlayButtons = new Set<string>();\n\n// Store audio elements to prevent memory leaks\nconst audioElements = new Map<string, HTMLAudioElement>();\n\n// Store prompts for each audio highlight\nconst promptStore = new Map<string, string>();\n\n// Store AudioContext\nlet audioContext: AudioContext | null = null;\n\nfunction removeHighlightAndAudio(audioKey: string) {\n  // Remove the audio element\n  if (audioElements.has(audioKey)) {\n    const audio = audioElements.get(audioKey);\n    audio?.pause();\n    audio?.remove();\n    audioElements.delete(audioKey);\n  }\n  \n  // Remove the prompt\n  promptStore.delete(audioKey);\n}\n\nfunction renderAudioMenu(\n  audioKey: string, \n  position: { top: number; left: number },\n  container: HTMLElement,\n  highlightId: string,\n  onClose: () => void\n) {\n  const root = createRoot(container);\n  const element = React.createElement(AudioMenu, {\n    audioKey,\n    position,\n    prompt: promptStore.get(audioKey) || '',\n    onClose: () => {\n      root.unmount();\n      document.body.removeChild(container);\n      activePlayButtons.delete(highlightId);\n      onClose();\n    }\n  });\n  root.render(element);\n  return root;\n}\n\nexport const AudioHighlight = Extension.create({\n  name: \"audioHighlight\",\n\n  addOptions() {\n    return {\n      HTMLAttributes: {\n        class: 'audio-highlight-text',\n      },\n    }\n  },\n\n  addGlobalAttributes() {\n    return [\n      {\n        types: ['highlight'],\n        attributes: {\n          audioKey: {\n            default: null,\n            parseHTML: element => element.getAttribute('data-audio-key'),\n            renderHTML: attributes => {\n              if (!attributes.audioKey) {\n                attributes.audioKey = `audio-${uuidv4()}`;\n              }\n              return {\n                'data-audio-key': attributes.audioKey,\n              }\n            },\n          },\n          toneEmoji: {\n            default: null,\n            parseHTML: element => element.getAttribute('data-tone-emoji'),\n            renderHTML: attributes => {\n              return {\n                'data-tone-emoji': attributes.toneEmoji,\n              }\n            },\n          },\n          prompt: {\n            default: null,\n            parseHTML: element => element.getAttribute('data-prompt'),\n            renderHTML: attributes => {\n              if (attributes.prompt) {\n                promptStore.set(attributes.audioKey, attributes.prompt);\n              }\n              return {\n                'data-prompt': attributes.prompt,\n              }\n            },\n          },\n        },\n      },\n    ]\n  },\n\n  addProseMirrorPlugins() {\n    return [\n      new Plugin({\n        key: new PluginKey(\"audio-highlight\"),\n        \n        appendTransaction(transactions, oldState, newState) {\n          if (!transactions.some(tr => tr.docChanged)) {\n            return null;\n          }\n\n          const tr = newState.tr;\n          let hasChanges = false;\n\n          const oldHighlights = new Map();\n          oldState.doc.descendants((node, pos) => {\n            const mark = node.marks.find(m => m.type.name === \"highlight\");\n            if (mark?.attrs.audioKey) {\n              oldHighlights.set(mark.attrs.audioKey, {\n                text: node.text,\n                pos,\n                mark\n              });\n            }\n          });\n\n          newState.doc.descendants((node, pos) => {\n            const mark = node.marks.find(m => m.type.name === \"highlight\");\n            if (mark?.attrs.audioKey) {\n              const oldHighlight = oldHighlights.get(mark.attrs.audioKey);\n              if (oldHighlight && oldHighlight.text !== node.text) {\n                tr.removeMark(pos, pos + node.nodeSize, mark.type);\n                removeHighlightAndAudio(mark.attrs.audioKey);\n                hasChanges = true;\n              }\n              oldHighlights.delete(mark.attrs.audioKey);\n            }\n          });\n\n          oldHighlights.forEach((highlight, audioKey) => {\n            removeHighlightAndAudio(audioKey);\n          });\n\n          return hasChanges ? tr : null;\n        },\n\n        props: {\n          decorations: (state) => {\n            const { doc } = state;\n            const decorations: Decoration[] = [];\n\n            doc.descendants((node, pos) => {\n              const highlightMark = node.marks.find(mark => mark.type.name === \"highlight\");\n              if (highlightMark) {\n                const audioKey = highlightMark.attrs.audioKey;\n                decorations.push(\n                  Decoration.inline(pos, pos + node.nodeSize, {\n                    class: \"audio-highlight-text\",\n                    \"data-audio-key\": audioKey || \"\",\n                    \"data-tone-emoji\": highlightMark.attrs.toneEmoji || \"\",\n                  })\n                );\n              }\n            });\n\n            return DecorationSet.create(doc, decorations);\n          },\n        },\n\n        view(editorView) {\n          const handleMouseOver = (event: MouseEvent) => {\n            const target = event.target as HTMLElement;\n            const highlightEl = target.closest(\".audio-highlight-text\");\n            \n            if (highlightEl) {\n              const audioKey = highlightEl.getAttribute(\"data-audio-key\");\n              \n              if (!audioKey) {\n                console.warn(\"No audio key found for highlight element\");\n                return;\n              }\n              \n              const highlightId = `highlight-${highlightEl.getBoundingClientRect().top}-${highlightEl.getBoundingClientRect().left}`;\n              \n              if (activePlayButtons.has(highlightId)) {\n                return;\n              }\n\n              const menuContainer = document.createElement('div');\n              document.body.appendChild(menuContainer);\n              \n              const rect = highlightEl.getBoundingClientRect();\n              const position = {\n                top: rect.top + window.scrollY - 33,\n                left: rect.right + window.scrollX - 80,\n              };\n\n              activePlayButtons.add(highlightId);\n\n              const handleMouseLeave: MouseLeaveHandler = (event) => {\n                const relatedTarget = (event as MouseEvent).relatedTarget as Node;\n                if (!menuContainer.contains(relatedTarget) && \n                    !highlightEl.contains(relatedTarget)) {\n                  root.unmount();\n                  document.body.removeChild(menuContainer);\n                  activePlayButtons.delete(highlightId);\n                  highlightEl.removeEventListener('mouseleave', handleMouseLeave);\n                  menuContainer.removeEventListener('mouseleave', handleMouseLeave);\n                }\n              };\n\n              const root = renderAudioMenu(\n                audioKey,\n                position,\n                menuContainer,\n                highlightId,\n                () => {\n                  highlightEl.removeEventListener('mouseleave', handleMouseLeave);\n                  menuContainer.removeEventListener('mouseleave', handleMouseLeave);\n                }\n              );\n\n              highlightEl.addEventListener('mouseleave', handleMouseLeave);\n              menuContainer.addEventListener('mouseleave', handleMouseLeave);\n            }\n          };\n\n          editorView.dom.addEventListener(\"mouseover\", handleMouseOver);\n\n          return {\n            destroy: () => {\n              editorView.dom.removeEventListener(\"mouseover\", handleMouseOver);\n              activePlayButtons.clear();\n              audioElements.forEach((audio, key) => {\n                audio.pause();\n                audio.src = \"\";\n                audioElements.delete(key);\n              });\n            },\n          };\n        },\n      }),\n    ];\n  },\n}); "
  },
  {
    "path": "voice-cursor/src/components/editor/extensions.ts",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n  TiptapImage,\n  TiptapLink,\n  UpdatedImage,\n  TaskList,\n  TaskItem,\n  HorizontalRule,\n  StarterKit,\n  Placeholder,\n  AIHighlight,\n} from \"novel/extensions\";\nimport { UploadImagesPlugin } from \"novel/plugins\";\nimport { cx } from \"class-variance-authority\";\n\n// Configure AI highlight with container class\nconst aiHighlight = AIHighlight.configure({\n  HTMLAttributes: {\n    class: \"highlight-container\",\n  },\n});\n\n// Configure base extensions with styling\nconst placeholder = Placeholder;\nconst tiptapLink = TiptapLink.configure({\n  HTMLAttributes: {\n    class: cx(\n      \"text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer\",\n    ),\n  },\n});\n\nconst tiptapImage = TiptapImage.extend({\n  addProseMirrorPlugins() {\n    return [\n      UploadImagesPlugin({\n        imageClass: cx(\"opacity-40 rounded-lg border border-stone-200\"),\n      }),\n    ];\n  },\n}).configure({\n  allowBase64: true,\n  HTMLAttributes: {\n    class: cx(\"rounded-lg border border-muted\"),\n  },\n});\n\nconst updatedImage = UpdatedImage.configure({\n  HTMLAttributes: {\n    class: cx(\"rounded-lg border border-muted\"),\n  },\n});\n\nconst taskList = TaskList.configure({\n  HTMLAttributes: {\n    class: cx(\"not-prose pl-2\"),\n  },\n});\n\nconst taskItem = TaskItem.configure({\n  HTMLAttributes: {\n    class: cx(\"flex gap-2 items-start my-4\"),\n  },\n  nested: true,\n});\n\nconst horizontalRule = HorizontalRule.configure({\n  HTMLAttributes: {\n    class: cx(\"mt-4 mb-6 border-t border-muted-foreground\"),\n  },\n});\n\nconst starterKit = StarterKit.configure({\n  bulletList: {\n    HTMLAttributes: {\n      class: cx(\"list-disc list-outside leading-3 -mt-2\"),\n    },\n  },\n  orderedList: {\n    HTMLAttributes: {\n      class: cx(\"list-decimal list-outside leading-3 -mt-2\"),\n    },\n  },\n  listItem: {\n    HTMLAttributes: {\n      class: cx(\"leading-normal -mb-2\"),\n    },\n  },\n  blockquote: {\n    HTMLAttributes: {\n      class: cx(\"border-l-4 border-primary\"),\n    },\n  },\n  codeBlock: {\n    HTMLAttributes: {\n      class: cx(\"rounded-md bg-muted text-muted-foreground border p-5 font-mono font-medium\"),\n    },\n  },\n  code: {\n    HTMLAttributes: {\n      class: cx(\"rounded-md bg-muted px-1.5 py-1 font-mono font-medium\"),\n      spellcheck: \"false\",\n    },\n  },\n  horizontalRule: false,\n  dropcursor: {\n    color: \"#DBEAFE\",\n    width: 4,\n  },\n  gapcursor: false,\n});\n\n// Export configured extensions\nexport const defaultExtensions = [\n  starterKit,\n  placeholder,\n  tiptapLink,\n  tiptapImage,\n  updatedImage,\n  taskList,\n  taskItem,\n  horizontalRule,\n  aiHighlight,\n];\n"
  },
  {
    "path": "voice-cursor/src/components/editor/image-upload.ts",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { createImageUpload } from \"novel/plugins\";\nimport { toast } from \"sonner\";\n\nconst onUpload = (file: File) => {\n  const promise = fetch(\"/api/upload\", {\n    method: \"POST\",\n    headers: {\n      \"content-type\": file?.type || \"application/octet-stream\",\n      \"x-vercel-filename\": file?.name || \"image.png\",\n    },\n    body: file,\n  });\n\n  return new Promise((resolve) => {\n    toast.promise(\n      promise.then(async (res) => {\n        // Successfully uploaded image\n        if (res.status === 200) {\n          const { url } = (await res.json()) as any;\n          // preload the image\n          let image = new Image();\n          image.src = url;\n          image.onload = () => {\n            resolve(url);\n          };\n          // No blob store configured\n        } else if (res.status === 401) {\n          resolve(file);\n          throw new Error(\n            \"`BLOB_READ_WRITE_TOKEN` environment variable not found, reading image locally instead.\",\n          );\n          // Unknown error\n        } else {\n          throw new Error(`Error uploading image. Please try again.`);\n        }\n      }),\n      {\n        loading: \"Uploading image...\",\n        success: \"Image uploaded successfully.\",\n        error: (e) => e.message,\n      },\n    );\n  });\n};\n\nexport const uploadFn = createImageUpload({\n  onUpload,\n  validateFn: (file) => {\n    if (!file.type.includes(\"image/\")) {\n      toast.error(\"File type not supported.\");\n      return false;\n    } else if (file.size / 1024 / 1024 > 20) {\n      toast.error(\"File size too big (max 20MB).\");\n      return false;\n    }\n    return true;\n  },\n});\n"
  },
  {
    "path": "voice-cursor/src/components/editor/prompt-popover.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { X } from 'lucide-react';\n\ninterface PromptPopoverProps {\n  position: { top: number; left: number };\n  prompt: string;\n  onClose: () => void;\n}\n\nexport function PromptPopover({ position, prompt, onClose }: PromptPopoverProps) {\n  return (\n    <div\n      className=\"absolute bg-zinc-700 text-gray-200 text-sm rounded-[16px]\n      \n      shadow-lg px-6 py-4 z-50 flex items-center gap-2\"\n      style={{\n        top: `${position.top}px`,\n        left: `${position.left}px`,\n        transform: 'translateY(-120%)',\n        minWidth: '280px',\n      }}\n    >\n      <div className=\"flex-1\">\n        <span className=\"font-medium\"><b>Prompt:</b> {prompt}</span>\n      </div>\n      <button\n        onClick={onClose}\n        className=\"text-gray-200 text-sm transition-colors\"\n      >\n        <X className=\"h-4 w-4\" />\n      </button>\n    </div>\n  );\n} "
  },
  {
    "path": "voice-cursor/src/components/editor/selectors/voice-popover.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Button } from \"@/components/ui/button\";\nimport { useState, useContext, useEffect } from \"react\";\nimport { Mic, Loader2, AlertCircle, Info } from \"lucide-react\";\nimport { useEditor } from \"novel\";\nimport { AudioBlobContext } from \"../advanced-editor\";\nimport { v4 as uuidv4 } from 'uuid';\nimport {\n    Tooltip,\n    TooltipContent,\n    TooltipProvider,\n    TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { TONE_OPTIONS, type ToneOption } from \"@/lib/tone-options\";\nimport { VOICE_OPTIONS, getVoiceColor } from \"@/lib/voice-options\";\n\ninterface VoicePopoverProps {\n    onGenerateComplete?: () => void;\n}\n\n// Define an interface for the mime parameters\ninterface MimeParams {\n    [key: string]: string | undefined;  // Allow undefined values\n    mimeBase?: string;\n    rate?: string;\n}\n\nexport const VoicePopover = ({ onGenerateComplete }: VoicePopoverProps) => {\n    const { editor } = useEditor();\n    const { setAudioBlob } = useContext(AudioBlobContext);\n    const [selectedVoice, setSelectedVoice] = useState<string>(\"Zephyr\");\n    const [selectedTone, setSelectedTone] = useState<string>(\"Neutral\");\n    const [isGenerating, setIsGenerating] = useState(false);\n    const [errorMessage, setErrorMessage] = useState<string | null>(null);\n    const [prompt, setPrompt] = useState<string>(\"\");\n\n    // Add effect to clear error message after 5 seconds\n    useEffect(() => {\n        if (errorMessage) {\n            const timer = setTimeout(() => {\n                setErrorMessage(null);\n            }, 5000);\n            return () => clearTimeout(timer);\n        }\n    }, [errorMessage]);\n\n    // Update the prompt whenever selection, voice, or tone changes\n    useEffect(() => {\n        if (editor) {\n            const { from, to } = editor.state.selection;\n            const selectedText = editor.state.doc.textBetween(from, to);\n            if (selectedText) {\n                const selectedToneOption = TONE_OPTIONS.find(t => t.name === selectedTone);\n                if (selectedToneOption?.transform) {\n                    setPrompt(selectedToneOption.transform(selectedText));\n                }\n            }\n        }\n    }, [selectedTone, editor?.state.selection.from, editor?.state.selection.to, editor]);\n\n    const generateAudio = async (text: string, voice: string, tone: string) => {\n        try {\n            console.log(\"Generating audio for:\", { text, voice, tone });\n            const textToSpeak = prompt;\n            console.log(\"-----Text to speak:\", textToSpeak);\n\n            const response = await fetch(\n                `https://generativelanguage.googleapis.com/v1beta/\n                models/gemini-2.0-flash-exp:generateContent?key=\n                ${process.env.NEXT_PUBLIC_GEMINI_API_KEY}`,\n                {\n                    method: \"POST\",\n                    headers: {\n                        \"Content-Type\": \"application/json\",\n                    },\n                    body: JSON.stringify({\n                        contents: [{\n                            parts: [{ text: textToSpeak }]\n                        }],\n                        generationConfig: {\n                            response_modalities: [\"AUDIO\"],\n                            speech_config: {\n                                voice_config: {\n                                    prebuilt_voice_config: {\n                                        voice_name: voice\n                                    }\n                                }\n                            }\n                        }\n                    })\n                }\n            );\n\n            const data = await response.json();\n            console.log(\"API Response:\", data);\n\n            if (!data.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data) {\n                console.error(\"No audio data in response:\", data);\n                throw new Error('No audio data received in response');\n            }\n\n            const base64Audio = data.candidates[0].content.parts[0].inlineData.data;\n            const mimeType = data.candidates[0].content.parts[0].inlineData.mimeType || 'audio/wav';\n            console.log(\"Received audio data:\", { mimeType, dataLength: base64Audio.length });\n\n            // Create WAV file from base64 data\n            const byteCharacters = atob(base64Audio);\n            const byteNumbers = new Array(byteCharacters.length);\n            for (let i = 0; i < byteCharacters.length; i++) {\n                byteNumbers[i] = byteCharacters.charCodeAt(i);\n            }\n            const byteArray = new Uint8Array(byteNumbers);\n\n            // Parse the audio format parameters\n            const mimeParams = mimeType.split(';').reduce((acc: MimeParams, param: string) => {\n                const [key, value] = param.split('=');\n                if (value) {\n                    acc[key.trim()] = value.trim();\n                } else {\n                    acc.mimeBase = key.trim();\n                }\n                return acc;\n            }, {} as MimeParams);\n\n            // Create WAV header\n            const wavHeader = new ArrayBuffer(44);\n            const view = new DataView(wavHeader);\n\n            // \"RIFF\" chunk descriptor\n            view.setUint32(0, 0x52494646, false); // \"RIFF\"\n            view.setUint32(4, 36 + byteArray.length, true); // file length\n            view.setUint32(8, 0x57415645, false); // \"WAVE\"\n\n            // \"fmt \" sub-chunk\n            view.setUint32(12, 0x666D7420, false); // \"fmt \"\n            view.setUint32(16, 16, true); // subchunk size\n            view.setUint16(20, 1, true); // PCM audio format\n            view.setUint16(22, 1, true); // Mono channel\n            view.setUint32(24, Number.parseInt(mimeParams.rate) || 24000, true); // sample rate\n            view.setUint32(28, (Number.parseInt(mimeParams.rate) || 24000) * 2, true); // byte rate\n            view.setUint16(32, 2, true); // block align\n            view.setUint16(34, 16, true); // bits per sample\n\n            // \"data\" sub-chunk\n            view.setUint32(36, 0x64617461, false); // \"data\"\n            view.setUint32(40, byteArray.length, true); // data length\n\n            // Combine header and PCM data\n            const wavBytes = new Uint8Array(wavHeader.byteLength + byteArray.length);\n            wavBytes.set(new Uint8Array(wavHeader), 0);\n            wavBytes.set(byteArray, wavHeader.byteLength);\n\n            // Create blob with WAV format\n            const audioBlob = new Blob([wavBytes], { type: 'audio/wav' });\n            console.log(\"Created audio blob:\", audioBlob);\n\n            return audioBlob;\n        } catch (error) {\n            console.error(\"Error generating audio:\", error);\n            throw error;\n        }\n    };\n\n    const handleGenerate = async () => {\n        if (!selectedVoice || !selectedTone || !editor) return;\n\n        setIsGenerating(true);\n        setErrorMessage(null); // Clear any previous errors\n        try {\n            // Get the selected text\n            const { from, to } = editor.state.selection;\n            const selectedText = editor.state.doc.textBetween(from, to);\n\n            if (!selectedText) {\n                setErrorMessage(\"Please select some text first\");\n                return;\n            }\n\n            console.log(\"Starting generation for:\", { selectedText, selectedVoice, selectedTone });\n\n            // Get the color for the selected voice\n            const voiceColor = getVoiceColor(selectedVoice);\n\n            // Generate audio\n            const audioBlob = await generateAudio(selectedText, selectedVoice, selectedTone);\n\n            // Generate a unique ID instead of using position\n            const audioKey = `audio-${uuidv4()}`;\n            setAudioBlob(audioKey, audioBlob);\n            console.log(\"Stored audio blob with key:\", audioKey);\n\n            // Store the prompt\n            (window as any).editorContext.promptStore = (window as any).editorContext.promptStore || new Map();\n            (window as any).editorContext.promptStore.set(audioKey, prompt);\n            console.log(\"Stored prompt with key:\", audioKey);\n\n            // Remove any existing highlights in the selection\n            editor.chain()\n                .focus()\n                .unsetHighlight()\n                .setTextSelection({ from, to })\n                .run();\n\n            // Add the new highlight with the voice's color and store the audio key as a data attribute\n            editor.chain()\n                .focus()\n                .setTextSelection({ from, to })\n                .setMark('highlight', {\n                    color: voiceColor,\n                    audioKey,\n                    tone: selectedTone,\n                    toneEmoji: TONE_OPTIONS.find(t => t.name === selectedTone)?.emoji || '',\n                    prompt: prompt\n                })\n                .run();\n\n            console.log(\"Applied highlight with color, audioKey, and prompt:\", { voiceColor, audioKey, prompt });\n\n            // Reset selection and close the popover\n            editor.commands.setTextSelection(to);\n            onGenerateComplete?.();\n        } catch (error) {\n            console.error(\"Error in handleGenerate:\", error);\n            setErrorMessage(error instanceof Error ? error.message : 'An error occurred while generating audio');\n            // Don't call onGenerateComplete here to keep the popover open\n        } finally {\n            setIsGenerating(false);\n        }\n    };\n\n    return (\n        <div className=\"flex flex-col gap-2 w-[450px]\">\n            <div className=\"flex gap-2\">\n                {/* Voice Column */}\n                <div className=\"w-1/3 border-r border-muted p-4\">\n                    <div className=\"mb-4 text-[8px] font-semibold text-muted-foreground\">\n                        VOICE\n                    </div>\n                    <div className=\"flex flex-col gap-1\">\n                        {VOICE_OPTIONS.map((voice) => (\n                            <Button\n                                key={voice.name}\n                                variant=\"ghost\"\n                                className={`justify-start px-2 h-8 text-xs ${selectedVoice === voice.name ? 'bg-blue-100 border border-blue-200' : 'border border-transparent'}`}\n                                onClick={() => {\n                                    setSelectedVoice(voice.name);\n                                    console.log(\"Selected voice:\", voice.name);\n                                }}\n                            >\n                                <div\n                                    className=\"w-3 h-3 rounded-full mr-2\"\n                                    style={{ backgroundColor: voice.color }}\n                                />\n                                {voice.name}\n                            </Button>\n                        ))}\n                    </div>\n                </div>\n\n                {/* Tone Column */}\n                <div className=\"w-2/3 p-4\">\n                    <div className=\"mb-4 text-[8px] font-semibold text-muted-foreground\">\n                        TONE\n                    </div>\n                    <div className=\"flex flex-col gap-1 h-full justify-between \">\n                        <div className=\"grid grid-cols-4 gap-1 gap-y-3 \">\n                            {TONE_OPTIONS.map((tone) => (\n                                <Button\n                                    key={tone.name}\n                                    variant=\"ghost\"\n                                    className={`flex flex-col items-center justify-center h-16 \n                                    ${selectedTone === tone.name ? 'bg-blue-100 border border-blue-200' : 'border border-transparent'}`}\n                                    onClick={() => {\n                                        setSelectedTone(tone.name);\n                                        console.log(\"Selected tone:\", tone.name);\n                                    }}\n                                >\n                                    <span className=\"text-3xl\">{tone.emoji}</span>\n                                    <span className=\"text-[9px]\">{tone.name}</span>\n                                </Button>\n                            ))}\n                        </div>\n                    </div>\n                </div>\n            </div>\n\n            <div className=\"p-4 border-t border-muted\">\n                {/* Error Message */}\n                {errorMessage && (\n                    <div className=\"mb-2 p-2 text-sm text-red-600 bg-red-50 border border-red-200 rounded flex items-center gap-2 animate-in fade-in slide-in-from-top-2 duration-300\">\n                        <AlertCircle className=\"h-4 w-4\" />\n                        {errorMessage}\n                    </div>\n                )}\n\n                {/* Prompt Display Section */}\n                <div className=\"mb-4\">\n                    <div className=\"flex border rounded-lg bg-gray-50 focus-within:ring-2 py-1\n                      focus-within:border-gray-400 focus-within:ring-transparent\">\n                        <div className=\"flex items-start pl-2 pt-2\">\n                            <img src=\"/pen_spark.svg\" alt=\"Edit\" className=\"h-6 w-6 text-muted-foreground\" />\n                        </div>\n                        <textarea\n                            value={prompt}\n                            onChange={(e) => setPrompt(e.target.value)}\n                            rows={2}\n                            className=\"w-full tracking-tight font-mono text-xs text-gray-500 focus:text-gray-700 px-1.5 py-2.5 border-none bg-transparent resize-none focus:outline-none\"\n                            style={{\n                                scrollbarWidth: 'thin',\n                                scrollbarColor: 'rgb(203 213 225) transparent'\n                            }}\n                        />\n                    </div>\n                </div>\n\n                {/* Generate Button */}\n                <Button\n                    className=\"w-full mb-0 text-sm rounded-full bg-blue-500 text-white hover:bg-blue-600\"\n                    disabled={isGenerating}\n                    onClick={handleGenerate}\n                >\n                    {isGenerating ? (\n                        <>\n                            <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n                            Generating...\n                        </>\n                    ) : (\n                        <>\n                            <Mic className=\"mr-2 h-4 w-4\" />\n                            Generate Voice\n                        </>\n                    )}\n                </Button>\n            </div>\n        </div>\n    );\n}; "
  },
  {
    "path": "voice-cursor/src/components/editor/slash-command.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n  CheckSquare,\n  Code,\n  Heading1,\n  Heading2,\n  Heading3,\n  ImageIcon,\n  List,\n  ListOrdered,\n  MessageSquarePlus,\n  Text,\n  TextQuote,\n} from \"lucide-react\";\nimport { createSuggestionItems } from \"novel/extensions\";\nimport { Command, renderItems } from \"novel/extensions\";\nimport { uploadFn } from \"./image-upload\";\n\nexport const suggestionItems = createSuggestionItems([\n  {\n    title: \"Text\",\n    description: \"Just start typing with plain text.\",\n    searchTerms: [\"p\", \"paragraph\"],\n    icon: <Text size={18} />,\n    command: ({ editor, range }) => {\n      editor\n        .chain()\n        .focus()\n        .deleteRange(range)\n        .toggleNode(\"paragraph\", \"paragraph\")\n        .run();\n    },\n  },\n  {\n    title: \"To-do List\",\n    description: \"Track tasks with a to-do list.\",\n    searchTerms: [\"todo\", \"task\", \"list\", \"check\", \"checkbox\"],\n    icon: <CheckSquare size={18} />,\n    command: ({ editor, range }) => {\n      editor.chain().focus().deleteRange(range).toggleTaskList().run();\n    },\n  },\n  {\n    title: \"Heading 1\",\n    description: \"Big section heading.\",\n    searchTerms: [\"title\", \"big\", \"large\"],\n    icon: <Heading1 size={18} />,\n    command: ({ editor, range }) => {\n      editor\n        .chain()\n        .focus()\n        .deleteRange(range)\n        .setNode(\"heading\", { level: 1 })\n        .run();\n    },\n  },\n  {\n    title: \"Heading 2\",\n    description: \"Medium section heading.\",\n    searchTerms: [\"subtitle\", \"medium\"],\n    icon: <Heading2 size={18} />,\n    command: ({ editor, range }) => {\n      editor\n        .chain()\n        .focus()\n        .deleteRange(range)\n        .setNode(\"heading\", { level: 2 })\n        .run();\n    },\n  },\n  {\n    title: \"Heading 3\",\n    description: \"Small section heading.\",\n    searchTerms: [\"subtitle\", \"small\"],\n    icon: <Heading3 size={18} />,\n    command: ({ editor, range }) => {\n      editor\n        .chain()\n        .focus()\n        .deleteRange(range)\n        .setNode(\"heading\", { level: 3 })\n        .run();\n    },\n  },\n  {\n    title: \"Bullet List\",\n    description: \"Create a simple bullet list.\",\n    searchTerms: [\"unordered\", \"point\"],\n    icon: <List size={18} />,\n    command: ({ editor, range }) => {\n      editor.chain().focus().deleteRange(range).toggleBulletList().run();\n    },\n  },\n  {\n    title: \"Numbered List\",\n    description: \"Create a list with numbering.\",\n    searchTerms: [\"ordered\"],\n    icon: <ListOrdered size={18} />,\n    command: ({ editor, range }) => {\n      editor.chain().focus().deleteRange(range).toggleOrderedList().run();\n    },\n  },\n  {\n    title: \"Quote\",\n    description: \"Capture a quote.\",\n    searchTerms: [\"blockquote\"],\n    icon: <TextQuote size={18} />,\n    command: ({ editor, range }) =>\n      editor\n        .chain()\n        .focus()\n        .deleteRange(range)\n        .toggleNode(\"paragraph\", \"paragraph\")\n        .toggleBlockquote()\n        .run(),\n  },\n  {\n    title: \"Code\",\n    description: \"Capture a code snippet.\",\n    searchTerms: [\"codeblock\"],\n    icon: <Code size={18} />,\n    command: ({ editor, range }) =>\n      editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),\n  },\n  {\n    title: \"Image\",\n    description: \"Upload an image from your computer.\",\n    searchTerms: [\"photo\", \"picture\", \"media\"],\n    icon: <ImageIcon size={18} />,\n    command: ({ editor, range }) => {\n      editor.chain().focus().deleteRange(range).run();\n      // upload image\n      const input = document.createElement(\"input\");\n      input.type = \"file\";\n      input.accept = \"image/*\";\n      input.onchange = async () => {\n        if (input.files?.length) {\n          const file = input.files[0];\n          const pos = editor.view.state.selection.from;\n          uploadFn(file, editor.view, pos);\n        }\n      };\n      input.click();\n    },\n  },\n]);\n\nexport const slashCommand = Command.configure({\n  suggestion: {\n    items: () => suggestionItems,\n    render: renderItems,\n  },\n});\n"
  },
  {
    "path": "voice-cursor/src/components/editor/unused-selectors/color-selector.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Check, ChevronDown } from \"lucide-react\";\nimport { EditorBubbleItem, useEditor } from \"novel\";\n\nimport {\n  PopoverTrigger,\n  Popover,\n  PopoverContent,\n} from \"@/components/ui/popover\";\nimport { Button } from \"@/components/ui/button\";\n\nexport interface BubbleColorMenuItem {\n  name: string;\n  color: string;\n}\n\nconst TEXT_COLORS: BubbleColorMenuItem[] = [\n  {\n    name: \"Default\",\n    color: \"var(--novel-black)\",\n  },\n  {\n    name: \"Purple\",\n    color: \"#9333EA\",\n  },\n  {\n    name: \"Red\",\n    color: \"#E00000\",\n  },\n  {\n    name: \"Yellow\",\n    color: \"#EAB308\",\n  },\n  {\n    name: \"Blue\",\n    color: \"#2563EB\",\n  },\n  {\n    name: \"Green\",\n    color: \"#008A00\",\n  },\n  {\n    name: \"Orange\",\n    color: \"#FFA500\",\n  },\n  {\n    name: \"Pink\",\n    color: \"#BA4081\",\n  },\n  {\n    name: \"Gray\",\n    color: \"#A8A29E\",\n  },\n];\n\nconst HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [\n  {\n    name: \"Default\",\n    color: \"var(--novel-highlight-default)\",\n  },\n  {\n    name: \"Purple\",\n    color: \"var(--novel-highlight-purple)\",\n  },\n  {\n    name: \"Red\",\n    color: \"var(--novel-highlight-red)\",\n  },\n  {\n    name: \"Yellow\",\n    color: \"var(--novel-highlight-yellow)\",\n  },\n  {\n    name: \"Blue\",\n    color: \"var(--novel-highlight-blue)\",\n  },\n  {\n    name: \"Green\",\n    color: \"var(--novel-highlight-green)\",\n  },\n  {\n    name: \"Orange\",\n    color: \"var(--novel-highlight-orange)\",\n  },\n  {\n    name: \"Pink\",\n    color: \"var(--novel-highlight-pink)\",\n  },\n  {\n    name: \"Gray\",\n    color: \"var(--novel-highlight-gray)\",\n  },\n];\n\ninterface ColorSelectorProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n}\n\nexport const ColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => {\n  const { editor } = useEditor();\n\n  if (!editor) return null;\n  const activeColorItem = TEXT_COLORS.find(({ color }) =>\n    editor.isActive(\"textStyle\", { color }),\n  );\n\n  const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>\n    editor.isActive(\"highlight\", { color }),\n  );\n\n  return (\n    <Popover modal={true} open={open} onOpenChange={onOpenChange}>\n      <PopoverTrigger asChild>\n        <Button size=\"sm\" className=\"gap-2 rounded-none\" variant=\"ghost\">\n          <span\n            className=\"rounded-sm px-1\"\n            style={{\n              color: activeColorItem?.color,\n              backgroundColor: activeHighlightItem?.color,\n            }}\n          >\n            A\n          </span>\n          <ChevronDown className=\"h-4 w-4\" />\n        </Button>\n      </PopoverTrigger>\n\n      <PopoverContent\n        sideOffset={5}\n        className=\"my-1 flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl \"\n        align=\"start\"\n      >\n        <div className=\"flex flex-col\">\n          <div className=\"my-1 px-2 text-sm font-semibold text-muted-foreground\">\n            Color\n          </div>\n          {TEXT_COLORS.map(({ name, color }, index) => (\n            <EditorBubbleItem\n              key={index}\n              onSelect={() => {\n                editor.commands.unsetColor();\n                name !== \"Default\" &&\n                  editor\n                    .chain()\n                    .focus()\n                    .setColor(color || \"\")\n                    .run();\n                onOpenChange(false);\n              }}\n              className=\"flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent\"\n            >\n              <div className=\"flex items-center gap-2\">\n                <div\n                  className=\"rounded-sm border px-2 py-px font-medium\"\n                  style={{ color }}\n                >\n                  A\n                </div>\n                <span>{name}</span>\n              </div>\n            </EditorBubbleItem>\n          ))}\n        </div>\n        <div>\n          <div className=\"my-1 px-2 text-sm font-semibold text-muted-foreground\">\n            Background\n          </div>\n          {HIGHLIGHT_COLORS.map(({ name, color }, index) => (\n            <EditorBubbleItem\n              key={index}\n              onSelect={() => {\n                editor.commands.unsetHighlight();\n                name !== \"Default\" && editor.chain().focus().setHighlight({ color }).run();\n                onOpenChange(false);\n              }}\n              className=\"flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent\"\n            >\n              <div className=\"flex items-center gap-2\">\n                <div\n                  className=\"rounded-sm border px-2 py-px font-medium\"\n                  style={{ backgroundColor: color }}\n                >\n                  A\n                </div>\n                <span>{name}</span>\n              </div>\n              {editor.isActive(\"highlight\", { color }) && (\n                <Check className=\"h-4 w-4\" />\n              )}\n            </EditorBubbleItem>\n          ))}\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "voice-cursor/src/components/editor/unused-selectors/highlight-selector.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Check, Highlighter } from \"lucide-react\";\nimport { EditorBubbleItem, useEditor } from \"novel\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { cn } from \"@/lib/utils\";\nimport { useState } from \"react\";\n\nconst HIGHLIGHT_COLORS = [\n  {\n    name: \"Default\",\n    color: \"#ffffff\",\n  },\n  {\n    name: \"Purple\",\n    color: \"rgba(147, 51, 234, 0.2)\",\n  },\n  {\n    name: \"Red\",\n    color: \"rgba(224, 0, 0, 0.2)\",\n  },\n  {\n    name: \"Yellow\",\n    color: \"rgba(234, 179, 8, 0.2)\",\n  },\n  {\n    name: \"Blue\",\n    color: \"rgba(37, 99, 235, 0.2)\",\n  },\n  {\n    name: \"Green\",\n    color: \"rgba(0, 138, 0, 0.2)\",\n  },\n  {\n    name: \"Orange\",\n    color: \"rgba(255, 165, 0, 0.2)\",\n  },\n  {\n    name: \"Pink\",\n    color: \"rgba(186, 64, 129, 0.2)\",\n  },\n  {\n    name: \"Gray\",\n    color: \"rgba(168, 162, 158, 0.2)\",\n  },\n];\n\ninterface HighlightSelectorProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n}\n\nexport const HighlightSelector = ({ open, onOpenChange }: HighlightSelectorProps) => {\n  const { editor } = useEditor();\n  if (!editor) return null;\n\n  return (\n    <Popover open={open} onOpenChange={onOpenChange}>\n      <PopoverTrigger asChild>\n        <EditorBubbleItem\n          onSelect={() => {\n            onOpenChange(true);\n          }}\n        >\n          <Button size=\"sm\" className=\"rounded-none\" variant=\"ghost\">\n            <Highlighter\n              className={cn(\"h-4 w-4\", {\n                \"text-blue-500\": editor.isActive(\"highlight\"),\n              })}\n            />\n          </Button>\n        </EditorBubbleItem>\n      </PopoverTrigger>\n      <PopoverContent \n        align=\"start\" \n        className=\"w-48 p-1\"\n        sideOffset={5}\n      >\n        <div className=\"flex flex-col\">\n          <div className=\"my-1 px-2 text-sm font-semibold text-muted-foreground\">\n            Highlight Color\n          </div>\n          {HIGHLIGHT_COLORS.map((item) => (\n            <EditorBubbleItem\n              key={item.name}\n              onSelect={() => {\n                if (item.name === \"Default\") {\n                  editor.chain().focus().unsetHighlight().run();\n                } else {\n                  editor.chain().focus().toggleHighlight({ color: item.color }).run();\n                }\n                onOpenChange(false);\n              }}\n              className=\"flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm hover:bg-accent\"\n            >\n              <div className=\"flex items-center gap-2\">\n                <div\n                  className=\"rounded-sm border px-2 py-px font-medium\"\n                  style={{ backgroundColor: item.color }}\n                >\n                  A\n                </div>\n                <span>{item.name}</span>\n              </div>\n              {editor.isActive(\"highlight\", { color: item.color }) && (\n                <Check className=\"h-4 w-4\" />\n              )}\n            </EditorBubbleItem>\n          ))}\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}; "
  },
  {
    "path": "voice-cursor/src/components/editor/unused-selectors/link-selector.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { cn } from \"@/lib/utils\";\nimport { useEditor } from \"novel\";\nimport { Check, Trash } from \"lucide-react\";\nimport {\n  type Dispatch,\n  type FC,\n  type SetStateAction,\n  useEffect,\n  useRef,\n} from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  PopoverContent,\n  Popover,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\n\nexport function isValidUrl(url: string) {\n  try {\n    new URL(url);\n    return true;\n  } catch (e) {\n    return false;\n  }\n}\nexport function getUrlFromString(str: string) {\n  if (isValidUrl(str)) return str;\n  try {\n    if (str.includes(\".\") && !str.includes(\" \")) {\n      return new URL(`https://${str}`).toString();\n    }\n  } catch (e) {\n    return null;\n  }\n}\ninterface LinkSelectorProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n}\n\nexport const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => {\n  const inputRef = useRef<HTMLInputElement>(null);\n  const { editor } = useEditor();\n\n  // Autofocus on input by default\n  useEffect(() => {\n    inputRef.current && inputRef.current?.focus();\n  });\n  if (!editor) return null;\n\n  return (\n    <Popover modal={true} open={open} onOpenChange={onOpenChange}>\n      <PopoverTrigger asChild>\n        <Button\n          size=\"sm\"\n          variant=\"ghost\"\n          className=\"gap-2 rounded-none border-none\"\n        >\n          <p className=\"text-base\">↗</p>\n          <p\n            className={cn(\"underline decoration-stone-400 underline-offset-4\", {\n              \"text-blue-500\": editor.isActive(\"link\"),\n            })}\n          >\n            Link\n          </p>\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent align=\"start\" className=\"w-60 p-0\" sideOffset={10}>\n        <form\n          onSubmit={(e) => {\n            const target = e.currentTarget as HTMLFormElement;\n            e.preventDefault();\n            const input = target[0] as HTMLInputElement;\n            const url = getUrlFromString(input.value);\n            if (url) {\n              editor.chain().focus().setLink({ href: url }).run();\n              onOpenChange(false);\n            }\n          }}\n          className=\"flex  p-1 \"\n        >\n          <input\n            ref={inputRef}\n            type=\"text\"\n            placeholder=\"Paste a link\"\n            className=\"flex-1 bg-background p-1 text-sm outline-none\"\n            defaultValue={editor.getAttributes(\"link\").href || \"\"}\n          />\n          {editor.getAttributes(\"link\").href ? (\n            <Button\n              size=\"icon\"\n              variant=\"outline\"\n              type=\"button\"\n              className=\"flex h-8 items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800\"\n              onClick={() => {\n                editor.chain().focus().unsetLink().run();\n                onOpenChange(false);\n              }}\n            >\n              <Trash className=\"h-4 w-4\" />\n            </Button>\n          ) : (\n            <Button size=\"icon\" className=\"h-8\">\n              <Check className=\"h-4 w-4\" />\n            </Button>\n          )}\n        </form>\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "voice-cursor/src/components/editor/unused-selectors/node-selector.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n  Check,\n  ChevronDown,\n  Heading1,\n  Heading2,\n  Heading3,\n  TextQuote,\n  ListOrdered,\n  TextIcon,\n  Code,\n  CheckSquare,\n  type LucideIcon,\n} from \"lucide-react\";\nimport { EditorBubbleItem, EditorInstance, useEditor } from \"novel\";\n\nimport { Popover } from \"@radix-ui/react-popover\";\nimport { PopoverContent, PopoverTrigger } from \"@/components//ui/popover\";\nimport { Button } from \"@/components//ui/button\";\n\nexport type SelectorItem = {\n  name: string;\n  icon: LucideIcon;\n  command: (editor: EditorInstance) => void;\n  isActive: (editor: EditorInstance) => boolean;\n};\n\nconst items: SelectorItem[] = [\n  {\n    name: \"Text\",\n    icon: TextIcon,\n    command: (editor) => editor.chain().focus().clearNodes().run(),\n    // I feel like there has to be a more efficient way to do this – feel free to PR if you know how!\n    isActive: (editor) =>\n      editor.isActive(\"paragraph\") &&\n      !editor.isActive(\"bulletList\") &&\n      !editor.isActive(\"orderedList\"),\n  },\n  {\n    name: \"Heading 1\",\n    icon: Heading1,\n    command: (editor) =>\n      editor.chain().focus().clearNodes().toggleHeading({ level: 1 }).run(),\n    isActive: (editor) => editor.isActive(\"heading\", { level: 1 }),\n  },\n  {\n    name: \"Heading 2\",\n    icon: Heading2,\n    command: (editor) =>\n      editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run(),\n    isActive: (editor) => editor.isActive(\"heading\", { level: 2 }),\n  },\n  {\n    name: \"Heading 3\",\n    icon: Heading3,\n    command: (editor) =>\n      editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run(),\n    isActive: (editor) => editor.isActive(\"heading\", { level: 3 }),\n  },\n  {\n    name: \"To-do List\",\n    icon: CheckSquare,\n    command: (editor) =>\n      editor.chain().focus().clearNodes().toggleTaskList().run(),\n    isActive: (editor) => editor.isActive(\"taskItem\"),\n  },\n  {\n    name: \"Bullet List\",\n    icon: ListOrdered,\n    command: (editor) =>\n      editor.chain().focus().clearNodes().toggleBulletList().run(),\n    isActive: (editor) => editor.isActive(\"bulletList\"),\n  },\n  {\n    name: \"Numbered List\",\n    icon: ListOrdered,\n    command: (editor) =>\n      editor.chain().focus().clearNodes().toggleOrderedList().run(),\n    isActive: (editor) => editor.isActive(\"orderedList\"),\n  },\n  {\n    name: \"Quote\",\n    icon: TextQuote,\n    command: (editor) =>\n      editor.chain().focus().clearNodes().toggleBlockquote().run(),\n    isActive: (editor) => editor.isActive(\"blockquote\"),\n  },\n  {\n    name: \"Code\",\n    icon: Code,\n    command: (editor) =>\n      editor.chain().focus().clearNodes().toggleCodeBlock().run(),\n    isActive: (editor) => editor.isActive(\"codeBlock\"),\n  },\n];\ninterface NodeSelectorProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n}\n\nexport const NodeSelector = ({ open, onOpenChange }: NodeSelectorProps) => {\n  const { editor } = useEditor();\n  if (!editor) return null;\n\n  const activeItem = items.filter((item) => item.isActive(editor)).pop() ?? {\n    name: \"Multiple\",\n  };\n\n  return (\n    <Popover modal={true} open={open} onOpenChange={onOpenChange}>\n      <PopoverTrigger\n        asChild\n        className=\"gap-2 rounded-none border-none hover:bg-accent focus:ring-0\"\n      >\n        <Button size=\"sm\" variant=\"ghost\" className=\"gap-2\">\n          <span className=\"whitespace-nowrap text-sm\">{activeItem.name}</span>\n          <ChevronDown className=\"h-4 w-4\" />\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent sideOffset={5} align=\"start\" className=\"w-48 p-1\">\n        {items.map((item, index) => (\n          <EditorBubbleItem\n            key={index}\n            onSelect={(editor) => {\n              item.command(editor);\n              onOpenChange(false);\n            }}\n            className=\"flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm hover:bg-accent\"\n          >\n            <div className=\"flex items-center space-x-2\">\n              <div className=\"rounded-sm border p-1\">\n                <item.icon className=\"h-3 w-3\" />\n              </div>\n              <span>{item.name}</span>\n            </div>\n            {activeItem.name === item.name && <Check className=\"h-4 w-4\" />}\n          </EditorBubbleItem>\n        ))}\n      </PopoverContent>\n    </Popover>\n  );\n};\n"
  },
  {
    "path": "voice-cursor/src/components/editor/unused-selectors/text-buttons.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { cn } from \"@/lib/utils\";\nimport { EditorBubbleItem, useEditor } from \"novel\";\nimport {\n  BoldIcon,\n  ItalicIcon,\n  UnderlineIcon,\n  StrikethroughIcon,\n  CodeIcon,\n} from \"lucide-react\";\nimport type { SelectorItem } from \"./node-selector\";\nimport { Button } from \"@/components/ui/button\";\n\nexport const TextButtons = () => {\n  const { editor } = useEditor();\n  if (!editor) return null;\n\n  const items: SelectorItem[] = [\n    {\n      name: \"bold\",\n      isActive: (editor) => editor.isActive(\"bold\"),\n      command: (editor) => editor.chain().focus().toggleBold().run(),\n      icon: BoldIcon,\n    },\n    {\n      name: \"italic\",\n      isActive: (editor) => editor.isActive(\"italic\"),\n      command: (editor) => editor.chain().focus().toggleItalic().run(),\n      icon: ItalicIcon,\n    },\n    {\n      name: \"underline\",\n      isActive: (editor) => editor.isActive(\"underline\"),\n      command: (editor) => editor.chain().focus().toggleUnderline().run(),\n      icon: UnderlineIcon,\n    },\n    {\n      name: \"strike\",\n      isActive: (editor) => editor.isActive(\"strike\"),\n      command: (editor) => editor.chain().focus().toggleStrike().run(),\n      icon: StrikethroughIcon,\n    },\n    {\n      name: \"code\",\n      isActive: (editor) => editor.isActive(\"code\"),\n      command: (editor) => editor.chain().focus().toggleCode().run(),\n      icon: CodeIcon,\n    },\n  ];\n\n  return (\n    <div className=\"flex\">\n      {items.map((item) => (\n        <EditorBubbleItem\n          key={item.name}\n          onSelect={(editor) => {\n            item.command(editor);\n          }}\n        >\n          <Button size=\"sm\" className=\"rounded-none\" variant=\"ghost\">\n            <item.icon\n              className={cn(\"h-4 w-4\", {\n                \"text-blue-500\": item.isActive(editor),\n              })}\n            />\n          </Button>\n        </EditorBubbleItem>\n      ))}\n    </div>\n  );\n};\n"
  },
  {
    "path": "voice-cursor/src/components/theme-provider.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { ThemeProvider as NextThemesProvider } from \"next-themes\";\nimport { type ThemeProviderProps } from \"next-themes/dist/types\";\n\nexport function ThemeProvider({ children, ...props }: ThemeProviderProps) {\n  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;\n}\n"
  },
  {
    "path": "voice-cursor/src/components/theme-toggle.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { useTheme } from \"next-themes\";\n\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { MoonIcon, SunIcon } from \"lucide-react\";\n\nexport function ThemeToggle() {\n  const { setTheme } = useTheme();\n\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger asChild>\n        <Button variant=\"outline\" size=\"icon\">\n          <SunIcon className=\"h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0\" />\n          <MoonIcon className=\"absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100\" />\n          <span className=\"sr-only\">Toggle theme</span>\n        </Button>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\">\n        <DropdownMenuItem onClick={() => setTheme(\"light\")}>\n          Light\n        </DropdownMenuItem>\n        <DropdownMenuItem onClick={() => setTheme(\"dark\")}>\n          Dark\n        </DropdownMenuItem>\n        <DropdownMenuItem onClick={() => setTheme(\"system\")}>\n          System\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n"
  },
  {
    "path": "voice-cursor/src/components/ui/button.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst buttonVariants = cva(\n  \"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n        destructive:\n          \"bg-destructive text-destructive-foreground hover:bg-destructive/90\",\n        outline:\n          \"border border-input bg-background hover:bg-accent hover:text-accent-foreground\",\n        secondary:\n          \"bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n        ghost: \"hover:bg-accent hover:text-accent-foreground\",\n        link: \"text-primary underline-offset-4 hover:underline\",\n      },\n      size: {\n        default: \"h-10 px-4 py-2\",\n        sm: \"h-9 rounded-md px-3\",\n        lg: \"h-11 rounded-md px-8\",\n        icon: \"h-10 w-10\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  }\n)\n\nexport interface ButtonProps\n  extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  ({ className, variant, size, asChild = false, ...props }, ref) => {\n    const Comp = asChild ? Slot : \"button\"\n    return (\n      <Comp\n        className={cn(buttonVariants({ variant, size, className }))}\n        ref={ref}\n        {...props}\n      />\n    )\n  }\n)\nButton.displayName = \"Button\"\n\nexport { Button, buttonVariants }\n"
  },
  {
    "path": "voice-cursor/src/components/ui/dropdown-menu.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\"use client\"\n\nimport * as React from \"react\"\nimport * as DropdownMenuPrimitive from \"@radix-ui/react-dropdown-menu\"\nimport { Check, ChevronRight, Circle } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst DropdownMenu = DropdownMenuPrimitive.Root\n\nconst DropdownMenuTrigger = DropdownMenuPrimitive.Trigger\n\nconst DropdownMenuGroup = DropdownMenuPrimitive.Group\n\nconst DropdownMenuPortal = DropdownMenuPrimitive.Portal\n\nconst DropdownMenuSub = DropdownMenuPrimitive.Sub\n\nconst DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup\n\nconst DropdownMenuSubTrigger = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {\n    inset?: boolean\n  }\n>(({ className, inset, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubTrigger\n    ref={ref}\n    className={cn(\n      \"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent\",\n      inset && \"pl-8\",\n      className\n    )}\n    {...props}\n  >\n    {children}\n    <ChevronRight className=\"ml-auto h-4 w-4\" />\n  </DropdownMenuPrimitive.SubTrigger>\n))\nDropdownMenuSubTrigger.displayName =\n  DropdownMenuPrimitive.SubTrigger.displayName\n\nconst DropdownMenuSubContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.SubContent\n    ref={ref}\n    className={cn(\n      \"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n      className\n    )}\n    {...props}\n  />\n))\nDropdownMenuSubContent.displayName =\n  DropdownMenuPrimitive.SubContent.displayName\n\nconst DropdownMenuContent = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n  <DropdownMenuPrimitive.Portal>\n    <DropdownMenuPrimitive.Content\n      ref={ref}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className\n      )}\n      {...props}\n    />\n  </DropdownMenuPrimitive.Portal>\n))\nDropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName\n\nconst DropdownMenuItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Item>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {\n    inset?: boolean\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Item\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      inset && \"pl-8\",\n      className\n    )}\n    {...props}\n  />\n))\nDropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName\n\nconst DropdownMenuCheckboxItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>\n>(({ className, children, checked, ...props }, ref) => (\n  <DropdownMenuPrimitive.CheckboxItem\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className\n    )}\n    checked={checked}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <Check className=\"h-4 w-4\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.CheckboxItem>\n))\nDropdownMenuCheckboxItem.displayName =\n  DropdownMenuPrimitive.CheckboxItem.displayName\n\nconst DropdownMenuRadioItem = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>\n>(({ className, children, ...props }, ref) => (\n  <DropdownMenuPrimitive.RadioItem\n    ref={ref}\n    className={cn(\n      \"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n      className\n    )}\n    {...props}\n  >\n    <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n      <DropdownMenuPrimitive.ItemIndicator>\n        <Circle className=\"h-2 w-2 fill-current\" />\n      </DropdownMenuPrimitive.ItemIndicator>\n    </span>\n    {children}\n  </DropdownMenuPrimitive.RadioItem>\n))\nDropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName\n\nconst DropdownMenuLabel = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Label>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {\n    inset?: boolean\n  }\n>(({ className, inset, ...props }, ref) => (\n  <DropdownMenuPrimitive.Label\n    ref={ref}\n    className={cn(\n      \"px-2 py-1.5 text-sm font-semibold\",\n      inset && \"pl-8\",\n      className\n    )}\n    {...props}\n  />\n))\nDropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName\n\nconst DropdownMenuSeparator = React.forwardRef<\n  React.ElementRef<typeof DropdownMenuPrimitive.Separator>,\n  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>\n>(({ className, ...props }, ref) => (\n  <DropdownMenuPrimitive.Separator\n    ref={ref}\n    className={cn(\"-mx-1 my-1 h-px bg-muted\", className)}\n    {...props}\n  />\n))\nDropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName\n\nconst DropdownMenuShortcut = ({\n  className,\n  ...props\n}: React.HTMLAttributes<HTMLSpanElement>) => {\n  return (\n    <span\n      className={cn(\"ml-auto text-xs tracking-widest opacity-60\", className)}\n      {...props}\n    />\n  )\n}\nDropdownMenuShortcut.displayName = \"DropdownMenuShortcut\"\n\nexport {\n  DropdownMenu,\n  DropdownMenuTrigger,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuCheckboxItem,\n  DropdownMenuRadioItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuShortcut,\n  DropdownMenuGroup,\n  DropdownMenuPortal,\n  DropdownMenuSub,\n  DropdownMenuSubContent,\n  DropdownMenuSubTrigger,\n  DropdownMenuRadioGroup,\n}\n"
  },
  {
    "path": "voice-cursor/src/components/ui/popover.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\"use client\"\n\nimport * as React from \"react\"\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Popover = PopoverPrimitive.Root\n\nconst PopoverTrigger = PopoverPrimitive.Trigger\n\nconst PopoverContent = React.forwardRef<\n  React.ElementRef<typeof PopoverPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>\n>(({ className, align = \"center\", sideOffset = 4, ...props }, ref) => (\n  <PopoverPrimitive.Portal>\n    <PopoverPrimitive.Content\n      ref={ref}\n      align={align}\n      sideOffset={sideOffset}\n      className={cn(\n        \"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n        className\n      )}\n      {...props}\n    />\n  </PopoverPrimitive.Portal>\n))\nPopoverContent.displayName = PopoverPrimitive.Content.displayName\n\nexport { Popover, PopoverTrigger, PopoverContent }\n"
  },
  {
    "path": "voice-cursor/src/components/ui/separator.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\"use client\"\n\nimport * as React from \"react\"\nimport * as SeparatorPrimitive from \"@radix-ui/react-separator\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Separator = React.forwardRef<\n  React.ElementRef<typeof SeparatorPrimitive.Root>,\n  React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>\n>(\n  (\n    { className, orientation = \"horizontal\", decorative = true, ...props },\n    ref\n  ) => (\n    <SeparatorPrimitive.Root\n      ref={ref}\n      decorative={decorative}\n      orientation={orientation}\n      className={cn(\n        \"shrink-0 bg-border\",\n        orientation === \"horizontal\" ? \"h-[1px] w-full\" : \"h-full w-[1px]\",\n        className\n      )}\n      {...props}\n    />\n  )\n)\nSeparator.displayName = SeparatorPrimitive.Root.displayName\n\nexport { Separator }\n"
  },
  {
    "path": "voice-cursor/src/components/ui/sonner.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\"use client\"\n\nimport { useTheme } from \"next-themes\"\nimport { Toaster as Sonner } from \"sonner\"\n\ntype ToasterProps = React.ComponentProps<typeof Sonner>\n\nconst Toaster = ({ ...props }: ToasterProps) => {\n  const { theme = \"system\" } = useTheme()\n\n  return (\n    <Sonner\n      theme={theme as ToasterProps[\"theme\"]}\n      className=\"toaster group\"\n      toastOptions={{\n        classNames: {\n          toast:\n            \"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg\",\n          description: \"group-[.toast]:text-muted-foreground\",\n          actionButton:\n            \"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground\",\n          cancelButton:\n            \"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground\",\n        },\n      }}\n      {...props}\n    />\n  )\n}\n\nexport { Toaster }\n"
  },
  {
    "path": "voice-cursor/src/components/ui/tooltip.tsx",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n\"use client\"\n\nimport * as React from \"react\"\nimport * as TooltipPrimitive from \"@radix-ui/react-tooltip\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst TooltipProvider = TooltipPrimitive.Provider\n\nconst Tooltip = TooltipPrimitive.Root\n\nconst TooltipTrigger = TooltipPrimitive.Trigger\n\nconst TooltipContent = React.forwardRef<\n  React.ElementRef<typeof TooltipPrimitive.Content>,\n  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>\n>(({ className, sideOffset = 4, ...props }, ref) => (\n  <TooltipPrimitive.Content\n    ref={ref}\n    sideOffset={sideOffset}\n    className={cn(\n      \"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n      className\n    )}\n    {...props}\n  />\n))\nTooltipContent.displayName = TooltipPrimitive.Content.displayName\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }\n"
  },
  {
    "path": "voice-cursor/src/lib/tone-options.ts",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// Define interface for tone options\nexport interface ToneOption {\n    emoji: string;\n    name: string;\n    transform: (text: string) => string; // Now just returns the prompt string\n}\n\n// Tone options with transformations\nexport const TONE_OPTIONS: ToneOption[] = [\n    { \n        emoji: \"💬\", \n        name: \"Neutral\",\n        transform: (text) => `Say: \"${text}\"`\n    },\n    { \n        emoji: \"🔮\", \n        name: \"Mysterious\",\n        transform: (text) => `Say this like a dramatic wizard speaking very mysteriously: \"${text}\"`\n    },\n    { \n        emoji: \"😃\", \n        name: \"Excited\",\n        transform: (text) => `Say this like a very enthusiastic excited fast-talking friend: \"${text.toUpperCase()}!\"`\n    },\n    { \n        emoji: \"😮\", \n        name: \"Surprised\",\n        transform: (text) => `Say with genuine shock and amazement: \"Oh wow! ${text}!?\"`\n    },\n    { \n        emoji: \"😔\", \n        name: \"Sad\",\n        transform: (text) => `Say in a melancholic and dejected tone: \"*sigh* ${text}...\"`\n    },\n    { \n        emoji: \"😡\", \n        name: \"Angry\",\n        transform: (text) => `Say with intense anger and frustration: \"${text.toUpperCase()}!!!\"`\n    },\n    { \n        emoji: \"❓\", \n        name: \"Uncertain\",\n        transform: (text) => `Say this like a question, even if it's not a question, as if you are very uncertain and confused about what you're saying: \"Hmm... ${text}?\"`\n    },\n    { \n        emoji: \"🦗\", \n        name: \"Whispering\",\n        transform: (text) => `Whisper in a hushed, secretive voice: \"${text.toLowerCase()}\"`\n    },\n    { \n        emoji: \"🗯️\", \n        name: \"Yelling\",\n        transform: (text) => `Shout with maximum volume, with urgency like you are yelling at someone: \"${text.toUpperCase()}!!!\"`\n    },\n    { \n        emoji: \"🐢\", \n        name: \"Slow\",\n        transform: (text) => `Say very slowly and deliberately: \"${text.split(' ').join('... ')}...\"`\n    },\n    { \n        emoji: \"🐰\", \n        name: \"Fast\",\n        transform: (text) => `Say rapidly and energetically: \"${text.split(' ').join('-')}\"`\n    },\n    { \n        emoji: \"🏄\", \n        name: \"Surfer\",\n        transform: (text) => `Say this like a mellow, laid-back surfer, speaking slowly and using surfer slang: \"Woah... ${text}, like, totally radical!\"`\n    },\n    { \n        emoji: \"🎭\", \n        name: \"Shakespeare\",\n        transform: (text) => `Say this like a Shakespearean actor speaking a very dramatic monologue: \"${text}\"`\n    },\n    { \n        emoji: \"🏴‍☠️\", \n        name: \"Pirate\",\n        transform: (text) => `Say this like a pirate: \"Arrg, ${text.replace(/r/g, 'rrr')}... arrg\"`\n    }\n]; "
  },
  {
    "path": "voice-cursor/src/lib/utils.ts",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { type ClassValue, clsx } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"
  },
  {
    "path": "voice-cursor/src/lib/voice-options.ts",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// Voice options with associated colors\nexport const VOICE_OPTIONS = [\n    { name: \"Zephyr\", color: \"rgba(37, 99, 235, 0.2)\" },     // Blue\n    { name: \"Puck\", color: \"rgba(147, 51, 234, 0.2)\" },      // Purple\n    { name: \"Charon\", color: \"rgba(224, 0, 0, 0.2)\" },       // Red\n    { name: \"Kore\", color: \"rgba(234, 179, 8, 0.2)\" },       // Yellow\n    { name: \"Fenrir\", color: \"rgba(0, 138, 0, 0.2)\" },       // Green\n    { name: \"Leda\", color: \"rgba(255, 165, 0, 0.2)\" },       // Orange\n    { name: \"Orus\", color: \"rgba(186, 64, 129, 0.2)\" },      // Pink\n    { name: \"Gemini H\", color: \"rgba(168, 162, 158, 0.2)\" }, // Gray\n];\n\nexport const getVoiceColor = (voiceName: string): string => {\n    const voice = VOICE_OPTIONS.find(v => v.name === voiceName);\n    if (!voice) {\n        console.warn(`Voice color not found for \"${voiceName}\", using default color`);\n        return \"rgba(168, 162, 158, 0.2)\"; // Default gray color\n    }\n    return voice.color;\n}; "
  },
  {
    "path": "voice-cursor/tailwind.config.ts",
    "content": "/**\n * Copyright 2024 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n *     http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { Config } from \"tailwindcss\";\nconst { fontFamily } = require(\"tailwindcss/defaultTheme\");\n\nconst config = {\n  darkMode: [\"class\"],\n  content: [\n    \"./pages/**/*.{ts,tsx}\",\n    \"./components/**/*.{ts,tsx}\",\n    \"./app/**/*.{ts,tsx}\",\n    \"./src/**/*.{ts,tsx}\",\n  ],\n  prefix: \"\",\n  theme: {\n    container: {\n      center: true,\n      padding: \"2rem\",\n      screens: {\n        \"2xl\": \"1400px\",\n      },\n    },\n    extend: {\n      fontFamily: {\n        sans: [\"var(--font-sans)\", ...fontFamily.sans],\n      },\n      colors: {\n        border: \"hsl(var(--border))\",\n        input: \"hsl(var(--input))\",\n        ring: \"hsl(var(--ring))\",\n        background: \"hsl(var(--background))\",\n        foreground: \"hsl(var(--foreground))\",\n        primary: {\n          DEFAULT: \"hsl(var(--primary))\",\n          foreground: \"hsl(var(--primary-foreground))\",\n        },\n        secondary: {\n          DEFAULT: \"hsl(var(--secondary))\",\n          foreground: \"hsl(var(--secondary-foreground))\",\n        },\n        destructive: {\n          DEFAULT: \"hsl(var(--destructive))\",\n          foreground: \"hsl(var(--destructive-foreground))\",\n        },\n        muted: {\n          DEFAULT: \"hsl(var(--muted))\",\n          foreground: \"hsl(var(--muted-foreground))\",\n        },\n        accent: {\n          DEFAULT: \"hsl(var(--accent))\",\n          foreground: \"hsl(var(--accent-foreground))\",\n        },\n        popover: {\n          DEFAULT: \"hsl(var(--popover))\",\n          foreground: \"hsl(var(--popover-foreground))\",\n        },\n        card: {\n          DEFAULT: \"hsl(var(--card))\",\n          foreground: \"hsl(var(--card-foreground))\",\n        },\n      },\n      borderRadius: {\n        lg: \"var(--radius)\",\n        md: \"calc(var(--radius) - 2px)\",\n        sm: \"calc(var(--radius) - 4px)\",\n      },\n      keyframes: {\n        \"accordion-down\": {\n          from: { height: \"0\" },\n          to: { height: \"var(--radix-accordion-content-height)\" },\n        },\n        \"accordion-up\": {\n          from: { height: \"var(--radix-accordion-content-height)\" },\n          to: { height: \"0\" },\n        },\n      },\n      animation: {\n        \"accordion-down\": \"accordion-down 0.2s ease-out\",\n        \"accordion-up\": \"accordion-up 0.2s ease-out\",\n      },\n    },\n  },\n  plugins: [require(\"tailwindcss-animate\"), require(\"@tailwindcss/typography\")],\n} satisfies Config;\n\nexport default config;\n"
  },
  {
    "path": "voice-cursor/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n    \"allowJs\": true,\n    \"skipLibCheck\": true,\n    \"strict\": true,\n    \"noEmit\": true,\n    \"esModuleInterop\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"jsx\": \"preserve\",\n    \"incremental\": true,\n    \"plugins\": [\n      {\n        \"name\": \"next\"\n      }\n    ],\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  },\n  \"include\": [\"next-env.d.ts\", \"**/*.ts\", \"**/*.tsx\", \".next/types/**/*.ts\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  }
]