chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:30:30 +08:00
commit 914fea506e
2793 changed files with 802106 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# .env.example
API_KEY="your_gemini_api_key_here"
PORT=8080
+3
View File
@@ -0,0 +1,3 @@
/node_modules
.env
@@ -0,0 +1,36 @@
# Contributing to V-Start
First off, thank you for considering contributing to V-Start! We welcome any help, whether it's reporting a bug, suggesting a new feature, or writing code.
## How Can I Contribute?
### 🐛 Reporting Bugs
If you find a bug, please open an issue on our GitHub repository. When you report a bug, please include:
* **A clear and descriptive title.**
* **Steps to reproduce the bug.** Be as specific as possible.
* **What you expected to happen** versus what actually happened.
* **Screenshots or screen recordings** if they help illustrate the issue.
### ✨ Suggesting Enhancements
If you have an idea for a new feature or an improvement to an existing one, please open an issue to start a discussion. This allows us to coordinate our efforts and prevent duplication of work.
Please provide:
* **A clear and descriptive title.**
* **A detailed description of the proposed enhancement** and why you think it would be valuable.
* **Any mockups or examples** that might help explain your idea.
### 🔧 Pull Requests
We are happy to review pull requests! If you'd like to contribute code, please follow these steps:
1. **Fork the repository** and create your branch from `main`.
2. **Make your changes.** Please ensure your code follows the existing style of the project.
3. **Add comments** to any complex or non-obvious parts of your code.
4. **Ensure your code lints** without any errors.
5. **Open a pull request** with a clear title and a detailed description of the changes you've made.
Thank you for helping make V-Start better!
+21
View File
@@ -0,0 +1,21 @@
# Use an official Node.js image. The 'lts' (Long Term Support) version is stable.
FROM node:18-alpine
# Set the working directory inside the container to /app
WORKDIR /app
# Copy package.json and package-lock.json first to leverage Docker's caching.
# This makes subsequent builds faster if you only change your source code.
COPY package*.json ./
# Install all project dependencies
RUN npm install
# Copy the rest of your application's source code into the container
COPY . .
# Expose port 8080 to the outside world
EXPOSE 8080
# The command to run when the container starts
CMD [ "node", "server.js" ]
+51
View File
@@ -0,0 +1,51 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
You must give any other recipients of the Work or Derivative Works a copy of this License; and
You must cause any modified files to carry prominent notices stating that You changed the files; and
You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+231
View File
@@ -0,0 +1,231 @@
# V-Start: A Toolkit for Veo Prompting and Evaluation
[![GitHub license](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/GoogleCloudPlatform/generative-ai/blob/main/vision/sample-apps/V-Start/LICENSE)
**Author: [Wafae Bakkali](https://github.com/WafaeBakkali)**
V-Start is an experimental toolkit that helps users easily and quickly create effective prompts for Veo and evaluate how well generated videos align with their intended prompts. The main goal is to simplify the process of creating high-quality videos with Veo.
<img src="./data/V-Start.png" alt="V-Start Application Screenshot" width="700">
## ✨ Features
V-Start is divided into two main categories: Prompting and Evaluation.
### Prompting Tools
* **Prompt Generator**: Generate expert-level prompts based on [the ideal structure for Veo](https://medium.com/google-cloud/veo-3-a-detailed-prompting-guide-867985b46018), with support for both Text-to-Video and Image-to-Video and options for short or long outputs. For Image-to-Video, a base description is automatically generated from the uploaded image, which can then be customized.
* **Prompt Enhancer**: Improve an existing prompt by leveraging Gemini to enhance its cinematic detail and effectiveness.
* **Prompt Converter**: Convert prompts between different formats, such as Plain Text, JSON, YAML or XML.
* **Timeline Prompting**: Create multi-shot scenes by sequencing multiple prompts, defining the start and end times for each action to build a detailed narrative.
* **Gallery**: Explore a curated library of high-quality video examples and copy their prompts for inspiration.
### Evaluation Tools
* **Alignment Eval**: An autorater that provides an objective score (0-100%) of how well a video matches its prompt. You can evaluate a single prompt-video pair or process multiple pairs in bulk by pasting data directly into the tool or uploading a CSV file from your local machine. The tool works by breaking the prompt into sub-questions, and Gemini uses its Visual Question Answering (VQA) capabilities to score the video's alignment. All results can be stored for further analysis.
* **Side-by-Side Comparison**: Compare videos side-by-side to gather human feedback. Participate in existing studies (like prompt format evaluation) or create your own for qualitative evaluation. Results can be stored for further analysis.
## 🛠️ Tech Stack
* **Backend**: Node.js with Express.js
* **Frontend**: HTML, CSS, and modern vanilla JavaScript (ES modules)
* **Styling**: Tailwind CSS (via CDN) with a custom dark mode theme.
* **Core AI**: Google Gemini API (specifically gemini-2.5-pro)
* **Deployment**: The application also includes a Dockerfile for containerization.
## 📂 Project Structure
The repository is organized as follows:
```
/
├── .env.example # Example environment file for new contributors
├── .gitignore # Specifies files to be ignored by Git
├── CONTRIBUTING.md # Guidelines for contributing to the project
├── Dockerfile # Defines the Docker container for the application
├── index.html # The main HTML file for the single-page application
├── LICENSE # The Apache 2.0 open-source license for the project
├── package.json # Lists project dependencies and scripts
├── package-lock.json # Records exact versions of dependencies
├── README.md # The project's readme file
├── server.js # The Node.js/Express backend server
├── style.css # Main stylesheet for the application
├── data/ # Contains static data and assets
│ ├── V-Start.png # Screenshot of the application UI
│ └── veo-youtube-study.json # Data for the A/B evaluation study
└── src/ # Contains all frontend JavaScript source code
├── api.js # Handles the fetch call to the backend Gemini API
├── main.js # The main entry point for the frontend application logic
├── ui.js # Contains UI helper functions (e.g., notifications, toast:
├── features/ # Each file represents a major feature/tab in the UI
│ ├── alignment-eval.js
│ ├── converter.js
│ ├── enhancer.js
│ ├── eval.js
│ ├── gallery.js
│ ├── generator.js
│ └── timeline.js
└── templates/ # Contains the HTML templates for each feature
├── alignment-eval.html
├── converter.html
├── enhancer.html
├── eval.html
├── gallery.html
├── generator.html
└── timeline.html
```
## Authentication Setup
V-Start supports two authentication methods for flexibility:
### Method 1: Google Cloud Access Token
This method uses your own Google Cloud Project.
1. **Set up a Google Cloud Project:**
- Go to the [Google Cloud Console](https://console.cloud.google.com/)
- Create a new project or select an existing one
- Enable billing for your project
2. **Enable required APIs:**
```bash
# Set your project ID
export PROJECT_ID="your-gcp-project-id"
gcloud config set project $PROJECT_ID
# Enable Vertex AI API
gcloud services enable aiplatform.googleapis.com
```
3. **Install and authenticate gcloud CLI:**
- Install the [Google Cloud SDK](https://cloud.google.com/sdk/docs/install)
- Authenticate with your Google account:
```bash
gcloud auth login
```
4. **Get your access token:**
```bash
gcloud auth print-access-token
```
**Note:** Access tokens expire after 1 hour. You'll need to run this command again to get a new token when it expires.
5. **Use in V-Start:**
- In the V-Start UI, select "gcloud Access Token" as your authentication method
- Enter your Project ID and the access token from step 4
### Method 2: Google AI Studio API Key
1. **Get your API Key:**
- Go to [Google AI Studio](https://aistudio.google.com/app/apikey)
- Sign in with your Google account
- Click "Create API Key"
- Choose "Create API key in new project" or select an existing project
- Copy the generated API key
2. **Configure the application:**
- Add the API key to your .env file (see Installation section below)
- In the V-Start UI, select "API Key" as your authentication method
## 🚀 Getting Started (Local Development)
Follow these instructions to get a copy of the project up and running on your local machine.
### Prerequisites
* [Node.js](https://nodejs.org/) (v18 or later recommended)
* npm (usually comes with Node.js)
* Authentication setup (see Authentication Setup section above)
### Installation
1. **Clone the repository:**
```bash
git clone https://github.com/GoogleCloudPlatform/generative-ai.git
cd vision/sample-apps/V-Start
```
2. **Install NPM packages:**
```bash
npm install
```
3. **Set up your environment variables:**
Create a file named `.env` in the root of the project by copying the example file.
```bash
cp .env.example .env
```
Open the `.env` file and add your Gemini API Key (if using Method 2):
```
API_KEY=your_gemini_api_key_here
```
**Note:** If you're only using the Access Token method, you can leave the API_KEY empty.
4. **Run the server:**
```bash
npm start
```
5. Open your browser and navigate to `http://localhost:8080`.
## ☁️ Deployment to Cloud Run
The recommended way to deploy this application is directly from source to Google Cloud Run, secured with Identity-Aware Proxy (IAP). When you deploy from source, Cloud Build automatically uses the Dockerfile in your repository to build and deploy your container.
### Prerequisites
* A Google Cloud Project with billing enabled.
* The [Google Cloud SDK](https://cloud.google.com/sdk/docs/install) (gcloud CLI) installed and authenticated.
* Project ID already configured (see Authentication Setup section above)
### Step 1: Enable Required Services
```bash
# Enable required services (using the PROJECT_ID from Authentication Setup)
gcloud services enable run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com secretmanager.googleapis.com iap.googleapis.com
```
### Step 2: Secure Your API Key
Store your Gemini API key in Secret Manager.
```bash
# Create the secret
gcloud secrets create gemini-api-key --replication-policy="automatic"
# Add your API key value to the secret
printf "your_gemini_api_key_here" | gcloud secrets versions add gemini-api-key --data-file=-
```
### Step 3: Configure OAuth Consent Screen
This is required for IAP. In the Google Cloud Console, navigate to **APIs & Services → OAuth consent screen** and complete the setup wizard.
### Step 4: Deploy the Service
Deploy the application as a private service.
```bash
gcloud run deploy veo-start-app \
--source . \
--region us-central1 \
--no-allow-unauthenticated \
--set-env-vars="API_KEY=sm://${PROJECT_ID}/gemini-api-key/latest"
```
### Step 5: Grant Access Permissions
After deploying, make sure to enforce IAP by granting access permissions to authorized users or groups. For detailed instructions, please follow the official documentation.
**Official Guide**: [Securing Cloud Run services with IAP](https://cloud.google.com/iap/docs/enabling-cloud-run)
## License
This project is licensed under the Apache License, Version 2.0. See the [LICENSE](https://github.com/GoogleCloudPlatform/generative-ai/blob/main/vision/sample-apps/V-Start/LICENSE) file for the full license text.
## 🤝 Contributing
Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines on how to report bugs, suggest enhancements, or submit pull requests.
Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

@@ -0,0 +1,82 @@
[
{
"videoA": "https://www.youtube.com/embed/8LZRFk1Oq9c",
"videoB": "https://www.youtube.com/embed/5_i8c0DUvPs"
},
{
"videoA": "https://www.youtube.com/embed/0uvpkAWilUg",
"videoB": "https://www.youtube.com/embed/a9bGjdPy-II"
},
{
"videoA": "https://www.youtube.com/embed/n1-gxRdnMds",
"videoB": "https://www.youtube.com/embed/Mhl6DXFFmns"
},
{
"videoA": "https://www.youtube.com/embed/e9Ocpv1kaGI",
"videoB": "https://www.youtube.com/embed/xs-cgvK0lXs"
},
{
"videoA": "https://www.youtube.com/embed/c_nWZzdmfBw",
"videoB": "https://www.youtube.com/embed/57fwHRa1ysw"
},
{
"videoA": "https://www.youtube.com/embed/5RD_XTVc78U",
"videoB": "https://www.youtube.com/embed/NClZ6dHOOcM"
},
{
"videoA": "https://www.youtube.com/embed/nas9uhTao6g",
"videoB": "https://www.youtube.com/embed/T9JfAyFsFME"
},
{
"videoA": "https://www.youtube.com/embed/HIZjgwmGHtc",
"videoB": "https://www.youtube.com/embed/rrv22qr_4Tk"
},
{
"videoA": "https://www.youtube.com/embed/W4nIwf6hWNg",
"videoB": "https://www.youtube.com/embed/D_Jw_wfLpbg"
},
{
"videoA": "https://www.youtube.com/embed/-olV0cJrFhQ",
"videoB": "https://www.youtube.com/embed/WvAiE4PWdxc"
},
{
"videoA": "https://www.youtube.com/embed/2C19bcbf4pY",
"videoB": "https://www.youtube.com/embed/bk_snr9dTw8"
},
{
"videoA": "https://www.youtube.com/embed/TKfIPKk1izQ",
"videoB": "https://www.youtube.com/embed/89OyyuUxc_s"
},
{
"videoA": "https://www.youtube.com/embed/r4Jqg4SY-h4",
"videoB": "https://www.youtube.com/embed/8Pbubzz1iws"
},
{
"videoA": "https://www.youtube.com/embed/xCk5CTSD7iM",
"videoB": "https://www.youtube.com/embed/pZR_yZdMj1I"
},
{
"videoA": "https://www.youtube.com/embed/TkDGHXgbanM",
"videoB": "https://www.youtube.com/embed/pklcqQ94gsI"
},
{
"videoA": "https://www.youtube.com/embed/OYcZSvHmSLI",
"videoB": "https://www.youtube.com/embed/xVDOpr-Y8pQ"
},
{
"videoA": "https://www.youtube.com/embed/LHj3UTlfqHE",
"videoB": "https://www.youtube.com/embed/use3hkP2XTA"
},
{
"videoA": "https://www.youtube.com/embed/bA-VGhl7wDc",
"videoB": "https://www.youtube.com/embed/wUC48oUOwf4"
},
{
"videoA": "https://www.youtube.com/embed/UaJtLA-6Q8I",
"videoB": "https://www.youtube.com/embed/HMpjuNDOvjk"
},
{
"videoA": "https://www.youtube.com/embed/dBMa25frZd8",
"videoB": "https://www.youtube.com/embed/zZZKJ0UnPLU"
}
]
+172
View File
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>V-Start</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
// Tailwind dark mode configuration
tailwind.config = {
darkMode: 'class',
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
</head>
<body class="bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 transition-colors duration-300">
<header id="main-header" class="bg-gradient-to-r from-gray-800 to-gray-900 dark:from-gray-900 dark:to-black text-white shadow-2xl">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex justify-between items-start">
<div>
<h1 class="text-4xl md:text-5xl font-black tracking-tight">V-Start</h1>
<p class="mt-2 text-lg text-gray-300">A Toolkit for Veo Prompting and Evaluation</p>
</div>
<div class="flex items-center space-x-4">
<button id="theme-toggle" class="p-2 rounded-lg bg-gray-700 hover:bg-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700 transition-colors duration-200" title="Switch theme">
<svg id="theme-toggle-light-icon" class="w-5 h-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" style="display: none;">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"></path>
</svg>
<svg id="theme-toggle-dark-icon" class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" style="display: block;">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>
</button>
</div>
</div>
</div>
</header>
<main class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex flex-col md:flex-row gap-8">
<nav class="md:w-64">
<div class="space-y-6">
<div>
<h4 class="px-4 mb-2 text-lg font-bold text-gray-700 dark:text-gray-300">Prompting</h4>
<div class="space-y-2 bg-gray-200 dark:bg-gray-800 p-1.5 rounded-xl">
<button id="generator-main-tab" class="main-tab flex items-center justify-center md:justify-start w-full py-2.5 px-4 font-bold rounded-lg text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd" /></svg>
<span class="truncate">Generator</span>
</button>
<button id="enhancer-main-tab" class="main-tab flex items-center justify-center md:justify-start w-full py-2.5 px-4 font-bold rounded-lg text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zM12 2a1 1 0 01.967.744L14.146 7.2 17.5 9.134a1 1 0 010 1.732l-3.354 1.935-1.18 4.455a1 1 0 01-1.933 0L9.854 12.8l-3.354-1.935a1 1 0 010-1.732L10.146 7.2A1 1 0 0112 2z" clip-rule="evenodd" /></svg>
<span class="truncate">Enhancer</span>
</button>
<button id="converter-main-tab" class="main-tab flex items-center justify-center md:justify-start w-full py-2.5 px-4 font-bold rounded-lg text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M15.28 4.72a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 000 1.06l4.25 4.25a.75.75 0 11-1.06 1.06l-4.25-4.25a2.25 2.25 0 010-3.18l4.25-4.25a.75.75 0 011.06 0zm-8.56 0a.75.75 0 011.06 0l4.25 4.25a2.25 2.25 0 010 3.18l-4.25 4.25a.75.75 0 11-1.06-1.06l4.25-4.25a.75.75 0 000-1.06L4.72 5.78a.75.75 0 010-1.06z" clip-rule="evenodd" /></svg>
<span class="truncate">Converter</span>
</button>
<button id="timeline-main-tab" class="main-tab flex items-center justify-center md:justify-start w-full py-2.5 px-4 font-bold rounded-lg text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.414-1.415L11 10.586V6z" clip-rule="evenodd" /></svg>
<span class="truncate">Timeline</span>
</button>
<button id="gallery-main-tab" class="main-tab flex items-center justify-center md:justify-start w-full py-2.5 px-4 font-bold rounded-lg text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z" /></svg>
<span class="truncate">Gallery</span>
</button>
</div>
</div>
<div>
<h4 class="px-4 mb-2 text-lg font-bold text-gray-700 dark:text-gray-300">Evaluation</h4>
<div class="space-y-2 bg-gray-200 dark:bg-gray-800 p-1.5 rounded-xl">
<button id="alignment-eval-main-tab" class="main-tab flex items-center justify-center md:justify-start w-full py-2.5 px-4 font-bold rounded-lg text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" /></svg>
<span class="truncate">Alignment Eval</span>
</button>
<button id="eval-main-tab" class="main-tab flex items-center justify-center md:justify-start w-full py-2.5 px-4 font-bold rounded-lg text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" /><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" /></svg>
<span class="truncate">A/B Eval</span>
</button>
</div>
</div>
</div>
</nav>
<div class="flex-1">
<div id="auth-section-container" class="mb-8 bg-white dark:bg-gray-800 rounded-2xl shadow-xl border-l-4 border-indigo-500 dark:border-indigo-400 overflow-hidden">
<div id="auth-header" class="flex justify-between items-center p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700">
<h3 class="text-xl font-bold text-gray-800 dark:text-gray-200">Authentication</h3>
<div class="flex items-center">
<p class="text-sm text-gray-500 dark:text-gray-400 italic mr-2">(Click to hide)</p>
<svg id="auth-chevron" class="h-6 w-6 text-gray-600 dark:text-gray-300 transform transition-transform" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<div id="auth-content" class="px-6 pb-6 border-t border-gray-200 dark:border-gray-700 transition-all duration-300">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 pt-6">
<div>
<label for="auth-method-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Method</label>
<select id="auth-method-select" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
<option value="access-token" selected>gcloud Access Token</option>
<option value="api-key">API Key</option>
</select>
</div>
<div id="api-key-auth-section" style="display: none;">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">API Key Status</label>
<p class="text-sm text-gray-600 dark:text-gray-400 p-2">The application will securely use the API key configured on the server.</p>
</div>
<div id="access-token-auth-section">
<div class="mb-4">
<label for="project-id-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Your Google Cloud Project ID</label>
<input type="text" id="project-id-input" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" placeholder="e.g., my-gcp-project" autocomplete="off">
</div>
<div class="mb-4">
<label for="location-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Vertex AI Location</label>
<select id="location-input" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
<option value="us-central1" selected>us-central1 (Iowa)</option>
<option value="us-east1">us-east1 (South Carolina)</option>
<option value="us-east4">us-east4 (Virginia)</option>
<option value="us-west1">us-west1 (Oregon)</option>
<option value="us-west2">us-west2 (Los Angeles)</option>
<option value="us-west3">us-west3 (Salt Lake City)</option>
<option value="us-west4">us-west4 (Las Vegas)</option>
<option value="northamerica-northeast1">northamerica-northeast1 (Montreal)</option>
<option value="europe-west1">europe-west1 (Belgium)</option>
<option value="europe-west2">europe-west2 (London)</option>
<option value="europe-west3">europe-west3 (Frankfurt)</option>
<option value="europe-west4">europe-west4 (Netherlands)</option>
<option value="europe-west6">europe-west6 (Zurich)</option>
<option value="asia-east1">asia-east1 (Taiwan)</option>
<option value="asia-northeast1">asia-northeast1 (Tokyo)</option>
<option value="asia-northeast3">asia-northeast3 (Seoul)</option>
<option value="asia-south1">asia-south1 (Mumbai)</option>
<option value="asia-southeast1">asia-southeast1 (Singapore)</option>
<option value="australia-southeast1">australia-southeast1 (Sydney)</option>
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Select the region where your Vertex AI API is enabled
</p>
</div>
<div>
<label for="access-token-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Your gcloud Access Token</label>
<div class="flex items-center gap-2">
<input type="password" id="access-token-input" class="flex-grow w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" placeholder="Paste your access token" autocomplete="off">
<button id="validate-token-btn" class="py-2 px-4 bg-gray-600 dark:bg-gray-700 text-white rounded-md font-semibold hover:bg-gray-700 dark:hover:bg-gray-600">Validate</button>
</div>
<div id="access-token-status" class="text-xs mt-2 h-4"></div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Run <code class="bg-gray-200 dark:bg-gray-600 p-1 rounded">gcloud auth print-access-token</code> in your terminal to get a new token.
</p>
</div>
</div>
</div>
</div>
</div>
<div id="main-content"></div>
</div>
</div>
</main>
<footer class="bg-gray-800 dark:bg-black text-gray-400 dark:text-gray-500 mt-12">
<div class="container mx-auto px-6 py-4 text-center text-sm">
<p>Made by wafaeb@ with 💙 | <a href="mailto:wafaeb@google.com" class="hover:text-white underline">wafaeb@google.com</a></p>
</div>
</footer>
<div id="notification-toast"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "v-start",
"version": "1.0.0",
"description": "A toolkit for Veo prompting and evaluation.",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"@google/genai": "^1.17.0",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"node-fetch": "^3.3.2"
},
"keywords": [
"veo",
"gemini",
"generative-ai"
],
"author": "Wafae Bakkali",
"license": "Apache-2.0",
"type": "commonjs"
}
+398
View File
@@ -0,0 +1,398 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const express = require('express');
const path = require('path');
const fs = require('fs');
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
// Import the new Google Gen AI SDK
const { GoogleGenAI } = require('@google/genai');
require('dotenv').config();
const app = express();
const port = process.env.PORT || 8080;
// Middleware & Static File Serving
app.use(express.json({ limit: '50mb' }));
app.use(express.static(path.join(__dirname)));
// --- API ENDPOINTS ---
// 1. Endpoint to securely load the YouTube study data from a private file
app.get('/api/study/veo-youtube-study', (req, res) => {
const filePath = path.join(__dirname, 'data', 'veo-youtube-study.json');
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
console.error("Error reading study file:", err);
return res.status(500).json({ error: "Could not load the study data." });
}
try {
const jsonData = JSON.parse(data);
res.json(jsonData);
} catch (parseErr) {
console.error("Error parsing study JSON:", parseErr);
return res.status(500).json({ error: "Study data is corrupted." });
}
});
});
// 2. Endpoint to proxy video URLs to avoid CORS issues
app.get('/api/proxy-video', async (req, res) => {
const videoUrl = req.query.url;
if (!videoUrl) {
return res.status(400).json({ error: 'URL query parameter is required.' });
}
try {
console.log(`Proxying video from: ${videoUrl}`);
const videoResponse = await fetch(videoUrl);
if (!videoResponse.ok) {
throw new Error(`Failed to fetch video with status: ${videoResponse.statusText}`);
}
const contentType = videoResponse.headers.get('content-type');
if (contentType) res.setHeader('Content-Type', contentType);
videoResponse.body.pipe(res);
} catch (error) {
console.error('Error proxying video:', error.message);
res.status(500).json({ error: `Failed to proxy video. Reason: ${error.message}` });
}
});
// 3. Fast validation endpoint using Gemini Flash model
app.post('/api/validate-token', async (req, res) => {
const { projectId, accessToken, location } = req.body;
const validationLocation = location || 'us-central1';
console.log(`Fast validation using Flash for project: ${projectId} in location: ${validationLocation}`);
if (projectId && !accessToken) {
return res.status(400).json({
valid: false,
message: 'Access Token is required for validation.'
});
}
// Store original env variable to restore later
const originalAuthToken = process.env.GOOGLE_AUTH_TOKEN;
try {
let ai;
if (accessToken) {
// Access token validation
process.env.GOOGLE_AUTH_TOKEN = accessToken;
ai = new GoogleGenAI({
vertexai: true,
project: projectId,
location: validationLocation
});
} else {
// API key validation
const apiKey = process.env.API_KEY || process.env.GEMINI_API_KEY;
if (!apiKey) {
return res.json({
valid: false,
message: 'No API key configured on server.'
});
}
ai = new GoogleGenAI({
vertexai: false,
apiKey: apiKey
});
}
console.log('Running fast validation with gemini-2.0-flash...');
// Use Flash model for much faster validation
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: [{
role: "user",
parts: [{ text: "1+1" }] // Minimal math question
}],
generationConfig: {
maxOutputTokens: 1,
temperature: 0
}
});
// If we get here, credentials are valid
console.log('Flash validation successful');
res.json({
valid: true,
message: 'Credentials validated successfully!'
});
} catch (error) {
console.error('Validation error:', error.message);
// Use a generic error message for all credential-related failures
res.json({
valid: false,
message: 'Invalid credentials. Please check your configuration.'
});
} finally {
// Always restore the original environment variable
if (originalAuthToken !== undefined) {
process.env.GOOGLE_AUTH_TOKEN = originalAuthToken;
} else {
delete process.env.GOOGLE_AUTH_TOKEN;
}
}
});
// 4. Main Gemini API proxy endpoint using SDK
app.post('/api/generate', async (req, res) => {
console.log('========== Generate endpoint called ==========');
const { authMethod, accessToken, projectId, location, systemPrompt, contentParts } = req.body;
const model = 'gemini-2.5-pro';
// Debug logging
console.log('Request received with:', {
authMethod,
hasToken: !!accessToken,
hasProjectId: !!projectId,
location: location || 'us-central1',
systemPromptLength: systemPrompt?.length || 0,
contentPartsCount: contentParts?.length || 0
});
try {
let ai;
let response;
if (authMethod === 'access-token') {
// === ACCESS TOKEN PATH ===
if (!projectId || !accessToken) {
return res.status(400).json({
error: "Project ID and Access Token are required for gcloud auth.",
validationError: true
});
}
const vertexLocation = location || 'us-central1';
console.log(`Using Gen AI SDK with access token for project: ${projectId} in location: ${vertexLocation}`);
// The SDK can use access tokens through environment variables
// Store the original value to restore later
const originalAuthToken = process.env.GOOGLE_AUTH_TOKEN;
try {
// Set the access token as environment variable for the SDK
process.env.GOOGLE_AUTH_TOKEN = accessToken;
// Initialize SDK with Vertex AI configuration
// The SDK will automatically use the GOOGLE_AUTH_TOKEN we just set
ai = new GoogleGenAI({
vertexai: true,
project: projectId,
location: vertexLocation
});
// Build parts array
const parts = [];
// Add text prompt first
if (systemPrompt) {
parts.push({ text: systemPrompt });
console.log('Added system prompt to parts');
}
// Add any images/videos from contentParts
if (contentParts && contentParts.length > 0) {
contentParts.forEach((part, index) => {
if (part.inlineData && part.inlineData.data && part.inlineData.mimeType) {
parts.push({
inlineData: {
mimeType: part.inlineData.mimeType,
data: part.inlineData.data
}
});
console.log(`Added media to parts: ${part.inlineData.mimeType}`);
}
});
}
console.log(`Sending ${parts.length} parts via SDK to Vertex AI in ${vertexLocation}`);
// Generate content using the SDK
response = await ai.models.generateContent({
model: model,
contents: [{
role: "user",
parts: parts
}]
});
const text = response.text;
console.log('Response received, text length:', text.length);
res.json({ text: text.trim() });
} finally {
// Always restore the original environment variable
if (originalAuthToken !== undefined) {
process.env.GOOGLE_AUTH_TOKEN = originalAuthToken;
} else {
delete process.env.GOOGLE_AUTH_TOKEN;
}
}
} else {
// === API KEY PATH ===
const apiKey = process.env.API_KEY || process.env.GEMINI_API_KEY;
if (!apiKey) {
return res.status(500).json({
error: 'API Key is not configured on the server. Please check your .env file.',
validationError: true
});
}
console.log('Using Gen AI SDK with API key');
// Initialize SDK with API key
ai = new GoogleGenAI({
vertexai: false,
apiKey: apiKey
});
// Build parts array
const parts = [];
// Add text prompt first
if (systemPrompt) {
parts.push({ text: systemPrompt });
console.log('Added system prompt to parts');
}
// Add any images/videos from contentParts
if (contentParts && contentParts.length > 0) {
contentParts.forEach((part, index) => {
if (part.inlineData && part.inlineData.data && part.inlineData.mimeType) {
parts.push({
inlineData: {
mimeType: part.inlineData.mimeType,
data: part.inlineData.data
}
});
console.log(`Added media to parts: ${part.inlineData.mimeType}`);
}
});
}
console.log(`Sending ${parts.length} parts via SDK to Gemini API`);
// Generate content using the SDK
response = await ai.models.generateContent({
model: model,
contents: [{
role: "user",
parts: parts
}]
});
const text = response.text;
console.log('Response received, text length:', text.length);
res.json({ text: text.trim() });
}
} catch (error) {
console.error('Generation error:', error);
// Use generic error message for validation errors
if (error.message?.includes('401') || error.message?.includes('Unauthorized') || error.message?.includes('invalid')) {
return res.status(401).json({
error: 'Invalid credentials. Please check your configuration.',
validationError: true
});
}
if (error.message?.includes('API_KEY_INVALID')) {
return res.status(401).json({
error: 'Invalid credentials. Please check your configuration.',
validationError: true
});
}
if (error.message?.includes('403') || error.message?.includes('Permission denied')) {
return res.status(403).json({
error: 'Invalid credentials. Please check your configuration.',
validationError: true
});
}
if (error.message?.includes('404') || error.message?.includes('not found')) {
return res.status(404).json({
error: 'Invalid credentials. Please check your configuration.',
validationError: true
});
}
if (error.message?.includes('quota')) {
return res.status(429).json({
error: 'API quota exceeded. Please try again later.'
});
}
if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
return res.status(500).json({
error: 'Cannot connect to Google services. Check your internet connection.',
validationError: true
});
}
res.status(500).json({
error: 'An internal server error occurred: ' + error.message
});
}
});
// Test endpoint to verify server is running
app.get('/api/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
endpoints: [
'/api/health',
'/api/validate-token',
'/api/generate',
'/api/proxy-video',
'/api/study/veo-youtube-study'
],
sdkVersion: {
'@google/genai': require('@google/genai/package.json').version
}
});
});
// Root route to serve index.html
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});
// --- Server Start ---
app.listen(port, '0.0.0.0', () => {
console.log(`Server listening at http://localhost:${port}`);
console.log('Environment variables configured:');
console.log(' API_KEY/GEMINI_API_KEY:', !!(process.env.API_KEY || process.env.GEMINI_API_KEY));
console.log(' GOOGLE_CLOUD_PROJECT:', process.env.GOOGLE_CLOUD_PROJECT || 'Not set');
console.log(' GOOGLE_CLOUD_LOCATION:', process.env.GOOGLE_CLOUD_LOCATION || 'Not set (will use UI selection)');
});
+50
View File
@@ -0,0 +1,50 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export async function callGeminiApi(systemPrompt, contentParts = []) {
// Read the selected auth method and credentials from the global UI elements
const authMethod = document.getElementById('auth-method-select').value;
const accessToken = document.getElementById('access-token-input').value;
const projectId = document.getElementById('project-id-input').value;
const payload = {
authMethod,
accessToken,
projectId,
systemPrompt,
contentParts
};
try {
const response = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorBody = await response.json();
throw new Error(`API Error: ${errorBody.error || 'Unknown server error'}`);
}
const result = await response.json();
return result.text;
} catch (error) {
console.error("Error calling backend service:", error);
throw error;
}
}
@@ -0,0 +1,666 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { callGeminiApi } from '../api.js';
import { showToast } from '../ui.js';
let alignmentResults = [];
let loadedPairs = [];
let loadedPrompts = [];
let loadedVideos = [];
// --- HELPER FUNCTIONS ---
function extractJSON(text) {
try {
const match = text.match(/\{[\s\S]*\}/);
if (match) return JSON.parse(match[0]);
} catch (e) { /* Fallback */ }
throw new Error('Failed to parse valid JSON from the API response.');
}
function getScoreColor(score) {
if (score >= 80) return {
bg: 'bg-green-100 dark:bg-green-900',
text: 'text-green-800 dark:text-green-200',
border: 'border-green-500 dark:border-green-400',
label: 'Excellent'
};
if (score >= 60) return {
bg: 'bg-blue-100 dark:bg-blue-900',
text: 'text-blue-800 dark:text-blue-200',
border: 'border-blue-500 dark:border-blue-400',
label: 'Good'
};
if (score >= 40) return {
bg: 'bg-yellow-100 dark:bg-yellow-900',
text: 'text-yellow-800 dark:text-yellow-200',
border: 'border-yellow-500 dark:border-yellow-400',
label: 'Fair'
};
return {
bg: 'bg-red-100 dark:bg-red-900',
text: 'text-red-800 dark:text-red-200',
border: 'border-red-500 dark:border-red-400',
label: 'Poor'
};
}
function parsePromptsFromCSV(textContent) {
return textContent
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line.length > 0);
}
async function getVideoAsBase64(videoSource) {
let blob;
if (videoSource instanceof File) {
blob = videoSource;
} else if (typeof videoSource === 'string') {
const response = await fetch('/api/proxy-video?url=' + encodeURIComponent(videoSource));
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to fetch video via proxy: ' + response.statusText);
}
blob = await response.blob();
} else {
throw new Error('Invalid video source provided.');
}
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve({
base64Data: reader.result.split(',')[1],
mimeType: blob.type
});
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
// --- DRAG AND DROP FUNCTIONS ---
function initDragAndDrop(listId, itemsArray, updateCallback) {
const list = document.getElementById(listId);
if (!list) return;
let draggedElement = null;
list.addEventListener('dragstart', (e) => {
if (e.target.draggable) {
draggedElement = e.target;
e.target.style.opacity = '0.5';
e.dataTransfer.effectAllowed = 'move';
}
});
list.addEventListener('dragend', (e) => {
if (e.target.draggable) {
e.target.style.opacity = '';
}
});
list.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const afterElement = getDragAfterElement(list, e.clientY);
if (afterElement == null) {
list.appendChild(draggedElement);
} else {
list.insertBefore(draggedElement, afterElement);
}
});
list.addEventListener('drop', (e) => {
e.preventDefault();
// Get the new order
const items = [...list.querySelectorAll('[draggable="true"]')];
const newOrder = items.map(item => parseInt(item.dataset.index));
// Reorder the array
const reorderedItems = newOrder.map(oldIndex => itemsArray[oldIndex]);
// Update the original array
itemsArray.length = 0;
itemsArray.push(...reorderedItems);
// Re-render
updateCallback();
showToast('Items reordered successfully!', 'success');
});
}
function getDragAfterElement(container, y) {
const draggableElements = [...container.querySelectorAll('[draggable="true"]:not([style*="opacity"])')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
// --- HTML TEMPLATE LOADER ---
export async function getAlignmentEvalContent() {
try {
const response = await fetch('/src/features/templates/alignment-eval.html');
if (!response.ok) {
throw new Error('Network response was not ok: ' + response.statusText);
}
return await response.text();
} catch (error) {
console.error('Failed to fetch alignment-eval template:', error);
return '<div>Error loading content. Please check the console for details.</div>';
}
}
// --- INITIALIZATION AND LOGIC ---
function renderPromptsList() {
const promptsList = document.getElementById('prompts-list');
promptsList.innerHTML = '';
loadedPrompts.forEach((prompt, index) => {
const promptDiv = document.createElement('div');
promptDiv.className = 'flex items-center gap-2 p-2 bg-gray-100 dark:bg-gray-700 rounded cursor-move hover:shadow-md transition-all duration-200 border border-transparent hover:border-gray-300 dark:hover:border-gray-600';
promptDiv.draggable = true;
promptDiv.dataset.index = index;
const dragIcon = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>';
promptDiv.innerHTML = '<div class="text-gray-400 dark:text-gray-500 flex-shrink-0">' + dragIcon + '</div>' +
'<div class="font-bold text-gray-500 dark:text-gray-400 flex-shrink-0">' + (index + 1) + '.</div>' +
'<div class="flex-1 text-sm text-gray-800 dark:text-gray-200 truncate" title="' + prompt + '">' + prompt + '</div>';
promptsList.appendChild(promptDiv);
});
initDragAndDrop('prompts-list', loadedPrompts, renderPromptsList);
}
function renderVideosList() {
const videosList = document.getElementById('videos-list');
videosList.innerHTML = '';
loadedVideos.forEach((video, index) => {
const videoName = (video.source instanceof File) ? video.source.name : video.source.split('/').pop();
const videoDiv = document.createElement('div');
videoDiv.className = 'flex items-center gap-2 p-2 bg-gray-100 dark:bg-gray-700 rounded cursor-move hover:shadow-md transition-all duration-200 border border-transparent hover:border-gray-300 dark:hover:border-gray-600';
videoDiv.draggable = true;
videoDiv.dataset.index = index;
const dragIcon = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>';
videoDiv.innerHTML = '<div class="text-gray-400 dark:text-gray-500 flex-shrink-0">' + dragIcon + '</div>' +
'<div class="font-bold text-gray-500 dark:text-gray-400 flex-shrink-0">' + (index + 1) + '.</div>' +
'<div class="flex-1 text-sm text-gray-800 dark:text-gray-200 truncate" title="' + videoName + '">' + videoName + '</div>';
videosList.appendChild(videoDiv);
});
initDragAndDrop('videos-list', loadedVideos, renderVideosList);
}
function renderFinalPairs() {
const pairsContainer = document.getElementById('alignment-pairs-container');
pairsContainer.innerHTML = '';
}
export function initAlignmentEval() {
alignmentResults = [];
loadedPairs = [];
loadedPrompts = [];
loadedVideos = [];
const loadAndPairBtn = document.getElementById('load-and-pair-btn');
const clearAllBtn = document.getElementById('clear-all-btn');
const startEvalBtn = document.getElementById('start-alignment-eval-btn');
const downloadBtn = document.getElementById('download-alignment-results-btn');
const reorderSection = document.getElementById('reorder-section');
const evalActionsContainer = document.getElementById('eval-actions-container');
// STEP 1: Load inputs and automatically create pairs
const loadAndCreatePairs = async () => {
const csvInput = document.getElementById('prompts-csv-input');
const textInput = document.getElementById('prompts-text-input');
const videosFileInput = document.getElementById('batch-videos-input');
const videosUrlInput = document.getElementById('videos-url-input');
// Load prompts
loadedPrompts = [];
if (csvInput.files.length > 0) {
loadedPrompts = parsePromptsFromCSV(await csvInput.files[0].text());
} else if (textInput.value.trim()) {
loadedPrompts = textInput.value.split(';').map(p => p.trim()).filter(p => p);
}
// Load videos
loadedVideos = [];
if (videosFileInput.files.length > 0) {
const files = Array.from(videosFileInput.files);
loadedVideos = files.map(file => ({ source: file }));
} else if (videosUrlInput.value.trim()) {
const urls = videosUrlInput.value.split(/\r?\n/).map(url => url.trim()).filter(url => url);
loadedVideos = urls.map(url => ({ source: url }));
}
// Validation
if (loadedPrompts.length === 0) {
showToast('Please provide prompts either via CSV file or text input.', 'error');
return;
}
if (loadedVideos.length === 0) {
showToast('Please provide videos either via file upload or URL input.', 'error');
return;
}
if (loadedPrompts.length !== loadedVideos.length) {
showToast('Mismatch: ' + loadedPrompts.length + ' prompts and ' + loadedVideos.length + ' videos. Please ensure they match.', 'error');
return;
}
// Automatically create pairs
loadedPairs = loadedPrompts.map((prompt, i) => {
const video = loadedVideos[i];
const name = (video.source instanceof File) ? video.source.name : video.source.split('/').pop();
return {
id: i + 1,
prompt: prompt,
videoSource: video.source,
videoName: name
};
});
// Show reordering only if more than 1 pair
if (loadedPairs.length > 1) {
renderPromptsList();
renderVideosList();
reorderSection.classList.remove('hidden');
showToast('Created ' + loadedPairs.length + ' pairs. Drag to reorder if needed, then click "Start Evaluation".', 'success');
} else {
reorderSection.classList.add('hidden');
showToast('Created ' + loadedPairs.length + ' pair. Ready for evaluation!', 'success');
}
renderFinalPairs();
evalActionsContainer.classList.remove('hidden');
// Update pairs when reordering (only if more than 1 pair)
if (loadedPairs.length > 1) {
const updatePairsFromReorder = () => {
loadedPairs = loadedPrompts.map((prompt, i) => {
const video = loadedVideos[i];
const name = (video.source instanceof File) ? video.source.name : video.source.split('/').pop();
return {
id: i + 1,
prompt: prompt,
videoSource: video.source,
videoName: name
};
});
renderFinalPairs();
};
// Re-initialize drag and drop with the update callback
initDragAndDrop('prompts-list', loadedPrompts, () => {
renderPromptsList();
updatePairsFromReorder();
});
initDragAndDrop('videos-list', loadedVideos, () => {
renderVideosList();
updatePairsFromReorder();
});
}
};
const clearAll = () => {
loadedPairs = [];
loadedPrompts = [];
loadedVideos = [];
alignmentResults = [];
document.getElementById('alignment-pairs-container').innerHTML = '';
document.getElementById('prompts-list').innerHTML = '';
document.getElementById('videos-list').innerHTML = '';
reorderSection.classList.add('hidden');
evalActionsContainer.classList.add('hidden');
document.getElementById('alignment-results-container').classList.add('hidden');
document.getElementById('download-alignment-results-container').classList.add('hidden');
document.getElementById('prompts-csv-input').value = '';
document.getElementById('prompts-text-input').value = '';
document.getElementById('batch-videos-input').value = '';
document.getElementById('videos-url-input').value = '';
showToast('All data cleared.', 'info');
};
// STEP 2: Start evaluation
const startAlignmentEvaluation = async () => {
const btn = document.getElementById('start-alignment-eval-btn');
const resultsContainer = document.getElementById('alignment-results-container');
btn.disabled = true;
btn.innerHTML = '<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white inline" fill="none" viewBox="0 0 24 24">' +
'<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>' +
'<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>' +
'</svg>Evaluating...';
resultsContainer.classList.remove('hidden');
resultsContainer.innerHTML = '';
document.getElementById('download-alignment-results-container').classList.add('hidden');
alignmentResults = [];
for (const pair of loadedPairs) {
const resultCard = document.createElement('div');
resultCard.className = 'bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden border border-gray-200 dark:border-gray-700';
const loadingHTML = '<div class="bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 p-4 border-b border-gray-200 dark:border-gray-600">' +
'<h3 class="font-bold text-lg">Pair #' + pair.id + ': Evaluating...</h3>' +
'<p class="text-sm text-gray-600 dark:text-gray-400 mt-1 truncate" title="' + pair.videoName + '">' + pair.videoName + '</p>' +
'</div>' +
'<div class="p-6">' +
'<div class="flex items-center justify-center">' +
'<svg class="animate-spin h-8 w-8 text-cyan-600" fill="none" viewBox="0 0 24 24">' +
'<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>' +
'<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>' +
'</svg>' +
'</div>' +
'<p class="text-center text-gray-500 dark:text-gray-400 mt-3" id="progress-text-' + pair.id + '">Step 1: Preparing video data...</p>' +
'</div>';
resultCard.innerHTML = loadingHTML;
resultsContainer.appendChild(resultCard);
const progressText = document.getElementById('progress-text-' + pair.id);
try {
const videoData = await getVideoAsBase64(pair.videoSource);
const base64Data = videoData.base64Data;
const mimeType = videoData.mimeType;
// Use camelCase to match server.js expectations
const videoParts = [{
inlineData: { mimeType: mimeType, data: base64Data }
}];
progressText.textContent = 'Step 2: Analyzing video and generating score...';
// Clean the prompt to avoid syntax issues
const cleanPrompt = pair.prompt
.replace(/[\\`"'\n\r\t]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
// Build prompt using array join
const promptParts = [];
promptParts.push('You are an expert video-to-prompt alignment auditor. Your job is to perform STRICT, CRITICAL evaluation.');
promptParts.push('');
promptParts.push('PROMPT TO EVALUATE: ' + cleanPrompt);
promptParts.push('');
promptParts.push('CRITICAL INSTRUCTION: You MUST actually watch and analyze the video content. Do not make assumptions.');
promptParts.push('');
promptParts.push('EVALUATION PROTOCOL:');
promptParts.push('');
promptParts.push('STEP 1 - FIRST DESCRIBE WHAT YOU SEE:');
promptParts.push('Before evaluating, describe in one sentence what is actually visible in this video.');
promptParts.push('');
promptParts.push('STEP 2 - DECOMPOSE THE PROMPT:');
promptParts.push('Extract EVERY verifiable element from the prompt:');
promptParts.push('- SUBJECTS: All entities mentioned (people, animals, objects)');
promptParts.push('- ACTIONS: All verbs and movements described');
promptParts.push('- ATTRIBUTES: All descriptive details (colors, sizes, quantities, qualities)');
promptParts.push('- SETTINGS: Locations, environments, time of day, weather');
promptParts.push('- RELATIONSHIPS: Spatial arrangements, interactions between elements');
promptParts.push('');
promptParts.push('STEP 3 - DETERMINE QUESTION COUNT:');
promptParts.push('Count the total distinct elements extracted above. Generate questions based on complexity:');
promptParts.push('- 1-3 elements: Generate 3-5 questions');
promptParts.push('- 4-6 elements: Generate 5-8 questions');
promptParts.push('- 7-10 elements: Generate 8-12 questions');
promptParts.push('- 11-15 elements: Generate 12-18 questions');
promptParts.push('- 16+ elements: Generate 18-25 questions');
promptParts.push('');
promptParts.push('STEP 4 - IDENTIFY CORE REQUIREMENT:');
promptParts.push('What is the SINGLE most important element that defines this video?');
promptParts.push('');
promptParts.push('STEP 5 - CREATE VERIFICATION QUESTIONS:');
promptParts.push('Generate specific questions that test EXACT requirements:');
promptParts.push('- Each question must test ONE specific claim from the prompt');
promptParts.push('- If prompt says "red car", ask "Is the car red?" not just "Is there a car?"');
promptParts.push('- If prompt says "jumping", ask "Is [subject] jumping?" not "Is there movement?"');
promptParts.push('- Questions MUST have a mix of Yes and No answers based on what is actually in the video');
promptParts.push('');
promptParts.push('STEP 6 - EVALUATE THE VIDEO:');
promptParts.push('For each question, look at what is ACTUALLY in the video:');
promptParts.push('- YES: Element is clearly visible AND matches the exact requirement');
promptParts.push('- NO: Element is absent, wrong, or does not match the requirement');
promptParts.push('- UNCERTAIN: Only if genuinely impossible to determine');
promptParts.push('');
promptParts.push('CRITICAL EVALUATION RULES:');
promptParts.push('- You MUST look at the actual video content, not guess based on the prompt');
promptParts.push('- DEFAULT TO NO when in doubt');
promptParts.push('- Partial matches are NO (blue car when prompt says red = NO)');
promptParts.push('- Generic matches are NO (animal when prompt says cat = NO)');
promptParts.push('- If the video shows something completely different from the prompt, most answers should be NO');
promptParts.push('');
promptParts.push('STEP 7 - CALCULATE SCORE:');
promptParts.push('- Core element missing: Maximum 20 points');
promptParts.push('- Core element present: Start with 100 points');
promptParts.push('- For each NO: Deduct (80 / total_questions) points');
promptParts.push('- For each UNCERTAIN: Deduct (40 / total_questions) points');
promptParts.push('');
promptParts.push('OUTPUT FORMAT - Return ONLY this JSON (no markdown):');
promptParts.push('{');
promptParts.push(' "core_subject": "the main subject from the prompt",');
promptParts.push(' "core_subject_present": true or false based on actual video content,');
promptParts.push(' "holistic_alignment_score": 0 to 100,');
promptParts.push(' "answers": [');
promptParts.push(' {"question": "Specific question?", "answer": "Yes/No/Uncertain"}');
promptParts.push(' ]');
promptParts.push('}');
const systemPrompt = promptParts.join('\n');
console.log('Sending evaluation request for pair #' + pair.id);
console.log('Video data length:', base64Data ? base64Data.length : 0);
const resultRaw = await callGeminiApi(systemPrompt, videoParts);
console.log('Raw response for pair #' + pair.id + ':', resultRaw);
const resultData = extractJSON(resultRaw);
console.log('Parsed data for pair #' + pair.id + ':', JSON.stringify(resultData, null, 2));
const answers = resultData.answers || [];
const core_subject = resultData.core_subject || 'Unknown';
const core_subject_present = resultData.core_subject_present || false;
const holistic_alignment_score = resultData.holistic_alignment_score || 0;
const score = holistic_alignment_score;
// Validation check for evaluation quality
if (answers && answers.length > 2) {
const yesCount = answers.filter(a => String(a.answer).toLowerCase() === 'yes').length;
const noCount = answers.filter(a => String(a.answer).toLowerCase() === 'no').length;
const yesPercentage = (yesCount / answers.length) * 100;
console.log('Pair #' + pair.id + ' evaluation distribution: ' + yesCount + ' Yes, ' + noCount + ' No, ' + (answers.length - yesCount - noCount) + ' Uncertain');
if (yesPercentage === 100 && answers.length > 3) {
console.warn('Warning: All answers are YES for pair #' + pair.id + ' - evaluation might be too lenient');
}
}
alignmentResults.push({
pairId: pair.id,
prompt: pair.prompt,
videoName: pair.videoName,
score: score,
core_subject: core_subject,
core_subject_present: core_subject_present,
answers: answers
});
const colorScheme = getScoreColor(score);
// Build result HTML
let answersHTML = '';
answers.forEach((a, idx) => {
const answer = String(a.answer).toLowerCase();
const answerColor = answer === 'yes' ? 'text-green-700 dark:text-green-400' : answer === 'no' ? 'text-red-700 dark:text-red-400' : 'text-yellow-700 dark:text-yellow-400';
const answerIcon = answer === 'yes' ? '[YES]' : answer === 'no' ? '[NO]' : '[?]';
answersHTML += '<div class="flex items-start text-sm">' +
'<span class="' + answerColor + ' mr-2 font-bold">' + answerIcon + '</span>' +
'<div class="flex-1">' +
'<p class="font-semibold text-gray-800 dark:text-gray-200">' + (idx + 1) + '. ' + a.question + '</p>' +
'<p class="' + answerColor + ' font-medium">' + a.answer + '</p>' +
'</div>' +
'</div>';
});
const yesCountFinal = answers.filter(a => String(a.answer).toLowerCase() === 'yes').length;
resultCard.innerHTML = '<div class="' + colorScheme.bg + ' p-4 rounded-t-lg border-b-4 ' + colorScheme.border + '">' +
'<div class="flex justify-between items-start">' +
'<div>' +
'<h3 class="font-bold text-lg ' + colorScheme.text + '">Pair #' + pair.id + '</h3>' +
'<p class="text-sm text-gray-600 dark:text-gray-400 mt-1 truncate" title="' + pair.videoName + '">' + pair.videoName + '</p>' +
'</div>' +
'<div class="text-right flex-shrink-0 ml-4">' +
'<p class="text-4xl font-bold ' + colorScheme.text + '">' + score + '%</p>' +
'<p class="text-xs uppercase tracking-wide font-semibold ' + colorScheme.text + '">' + colorScheme.label + '</p>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="p-4 bg-white dark:bg-gray-800">' +
'<div class="mb-3 text-sm text-gray-600 dark:text-gray-400">' +
'<p><strong>Prompt:</strong> "' + pair.prompt + '"</p>' +
'<p class="mt-2"><strong>Evaluation:</strong> ' + answers.length + ' criteria checked</p>' +
'</div>' +
'<details>' +
'<summary class="cursor-pointer font-semibold text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200">View Detailed Evaluation</summary>' +
'<div class="mt-3 space-y-3">' +
'<div class="flex items-start text-sm">' +
'<span class="' + (core_subject_present ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400') + ' mr-2 font-bold">' + (core_subject_present ? '[YES]' : '[NO]') + '</span>' +
'<div class="flex-1">' +
'<p class="font-semibold text-gray-800 dark:text-gray-200">Core Subject: "' + core_subject + '"</p>' +
'<p class="' + (core_subject_present ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400') + '">' + (core_subject_present ? 'Present in video' : 'Missing from video') + '</p>' +
'</div>' +
'</div>' +
answersHTML +
'<div class="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">' +
'<p class="text-xs text-gray-500 dark:text-gray-400">' +
'Summary: ' + yesCountFinal + '/' + answers.length + ' criteria met' +
(!core_subject_present ? ' (Core subject missing - score capped at 20%)' : '') +
'</p>' +
'</div>' +
'</div>' +
'</details>' +
'</div>';
} catch (error) {
console.error('Error evaluating Pair #' + pair.id + ':', error);
resultCard.innerHTML = '<div class="p-4 bg-white dark:bg-gray-800">' +
'<h3 class="font-bold text-lg text-red-700 dark:text-red-400 mb-2">Error evaluating Pair #' + pair.id + '</h3>' +
'<p class="text-red-600 dark:text-red-400 text-sm">' + error.message + '</p>' +
'</div>';
showToast('Error on pair #' + pair.id + ': ' + error.message, 'error');
}
}
if (alignmentResults.length > 0) {
document.getElementById('download-alignment-results-container').classList.remove('hidden');
}
btn.disabled = false;
btn.innerHTML = 'Start Evaluation';
};
const downloadAlignmentResults = () => {
if (alignmentResults.length === 0) {
showToast("No results to download.", "error");
return;
}
const headers = ["Pair ID", "Prompt", "Video Name", "Overall Score", "Core Subject", "Core Subject Present", "Total Questions", "Questions Met", "Question", "Answer"];
let csvRows = [headers.join(',')];
alignmentResults.forEach(r => {
const escapeCsv = (str) => '"' + String(str).replace(/"/g, '""') + '"';
const totalQuestions = r.answers ? r.answers.length : 0;
const questionsMet = r.answers ? r.answers.filter(a => String(a.answer).toLowerCase() === 'yes').length : 0;
if (r.answers && r.answers.length > 0) {
r.answers.forEach((answer, idx) => {
const row = [
r.pairId,
escapeCsv(r.prompt),
escapeCsv(r.videoName),
r.score,
escapeCsv(r.core_subject),
r.core_subject_present,
totalQuestions,
questionsMet,
escapeCsv((idx + 1) + '. ' + answer.question),
escapeCsv(answer.answer)
];
csvRows.push(row.join(','));
});
} else {
const row = [
r.pairId,
escapeCsv(r.prompt),
escapeCsv(r.videoName),
r.score,
escapeCsv(r.core_subject),
r.core_subject_present,
0,
0,
"N/A",
"N/A"
];
csvRows.push(row.join(','));
}
});
const csvContent = csvRows.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
const dateStr = new Date().toISOString().slice(0,10);
link.download = 'alignment_evaluation_results_' + dateStr + '.csv';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showToast("Results downloaded successfully!", "success");
};
// Attach event listeners to the buttons
loadAndPairBtn.addEventListener('click', loadAndCreatePairs);
clearAllBtn.addEventListener('click', clearAll);
startEvalBtn.addEventListener('click', startAlignmentEvaluation);
downloadBtn.addEventListener('click', downloadAlignmentResults);
}
@@ -0,0 +1,90 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { callGeminiApi } from '../api.js';
import { showToast, copyToClipboard } from '../ui.js';
export async function getConverterContent() {
try {
const response = await fetch('/src/features/templates/converter.html');
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return await response.text();
} catch (error) {
console.error('Failed to fetch converter template:', error);
return '<div>Error loading content. Please check the console for details.</div>';
}
}
export function initConverter() {
window.copyToClipboard = copyToClipboard;
const convertBtn = document.getElementById('convert-format-btn');
const clearBtn = document.getElementById('clear-converter-btn');
const converterInput = document.getElementById('converter-input');
const formatSelect = document.getElementById('format-select');
const outputContainer = document.getElementById('converter-output-container');
const outputElement = document.getElementById('converter-output');
const clearAll = () => {
converterInput.value = '';
outputElement.textContent = '';
outputContainer.style.display = 'none';
formatSelect.selectedIndex = 0;
showToast('Converter form cleared!', 'info');
};
convertBtn.addEventListener('click', async () => {
const inputText = converterInput.value;
const targetFormat = formatSelect.value;
if (!inputText.trim()) {
showToast('Please enter a prompt to convert.', 'error');
return;
}
const systemPrompt = `You are a data format converter. Convert the following text into the ${targetFormat} format. Provide only the converted text as a raw string, without any additional explanation or markdown code fences (e.g., \`\`\`json). Text to convert:\n\n${inputText}`;
const originalButtonHtml = convertBtn.innerHTML;
convertBtn.disabled = true;
convertBtn.innerHTML = `
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Converting...
`;
outputContainer.style.display = 'block';
outputElement.textContent = `Converting to ${targetFormat}...`;
try {
const convertedText = await callGeminiApi(systemPrompt);
outputElement.textContent = convertedText;
showToast('Format converted successfully!', 'success');
} catch (error) {
console.error('Conversion error:', error);
outputElement.textContent = `Error: ${error.message}`;
showToast('Failed to convert the format.', 'error');
} finally {
convertBtn.disabled = false;
convertBtn.innerHTML = originalButtonHtml;
}
});
clearBtn.addEventListener('click', clearAll);
}
@@ -0,0 +1,88 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { callGeminiApi } from '../api.js';
import { showToast, copyToClipboard } from '../ui.js';
export async function getEnhancerContent() {
try {
const response = await fetch('/src/features/templates/enhancer.html');
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return await response.text();
} catch (error) {
console.error('Failed to fetch enhancer template:', error);
return '<div>Error loading content. Please check the console for details.</div>';
}
}
// --- INITIALIZATION AND LOGIC ---
export function initEnhancer() {
window.copyToClipboard = copyToClipboard;
const enhanceBtn = document.getElementById('enhance-prompt-btn');
const clearBtn = document.getElementById('clear-enhancer-btn');
const enhancerInput = document.getElementById('enhancer-input');
const outputContainer = document.getElementById('enhancer-output-container');
const outputElement = document.getElementById('enhancer-output');
clearBtn.addEventListener('click', () => {
enhancerInput.value = '';
outputContainer.classList.add('hidden');
outputElement.textContent = '';
showToast('Enhancer form cleared!', 'info');
});
enhanceBtn.addEventListener('click', async () => {
const originalPrompt = enhancerInput.value;
if (!originalPrompt.trim()) {
showToast('Please enter a prompt to enhance.', 'error');
return;
}
const systemPrompt = `You are a world-class video director and an expert prompt engineer for Google's Veo model. Your task is to significantly enhance and improve the following prompt. Make it more cinematic, detailed, descriptive, and effective for an AI video generation model. Add specific details about camera work, lighting, mood, and visual style. Here is the prompt to improve: "${originalPrompt}"
Output ONLY the final, enhanced prompt string, without any introduction, explanation, or quotation marks.`;
const originalButtonHtml = enhanceBtn.innerHTML;
enhanceBtn.disabled = true;
enhanceBtn.innerHTML = `
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Enhancing...
`;
outputContainer.classList.remove('hidden');
outputElement.textContent = 'Generating a more cinematic version...';
try {
const enhancedPrompt = await callGeminiApi(systemPrompt);
outputElement.textContent = enhancedPrompt;
showToast('Prompt enhanced successfully!', 'success');
} catch (error) {
console.error('Enhancement error:', error);
outputElement.textContent = `Error: ${error.message}`;
showToast('Failed to enhance the prompt.', 'error');
} finally {
enhanceBtn.disabled = false;
enhanceBtn.innerHTML = originalButtonHtml;
}
});
}
@@ -0,0 +1,384 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { showToast } from '../ui.js';
// --- MODULE-LEVEL STATE ---
const questions = [
{
id: 'q_quality_comparison',
text: 'How would you rate the overall quality of Video A compared to Video B?',
options: [
'Video A is higher quality',
'Video B is higher quality',
'They are about the same quality'
]
}
];
let evaluationPairs = [];
let currentPairIndex = 0;
let evaluationResults = [];
let isPreloadedStudy = false;
// --- DATA FETCHING ---
async function fetchVeoYouTubeStudy() {
try {
const response = await fetch('/data/veo-youtube-study.json');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Error fetching YouTube study data:", error);
showToast("Could not load the YouTube study. Please try again later.", "error");
return [];
}
}
// --- HTML TEMPLATE ---
export async function getEvalContent() {
try {
const response = await fetch('/src/features/templates/eval.html');
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return await response.text();
} catch (error) {
console.error('Failed to fetch eval template:', error);
return '<div>Error loading content. Please check the console for details.</div>';
}
}
// --- MAIN INITIALIZATION AND LOGIC ---
export function initEval() {
const choiceSection = document.getElementById('choice-section');
const setupSection = document.getElementById('setup-section');
const mainEvalContent = document.getElementById('main-eval-content');
const thankYouSection = document.getElementById('thank-you-section');
const participateButton = document.getElementById('participate-button');
const createButton = document.getElementById('create-button');
const backButtonCreator = document.getElementById('back-button-creator');
const startEvalButtonCreator = document.getElementById('start-eval-button-creator');
const submitButton = document.getElementById('submit-vote');
const previousButton = document.getElementById('previous-pair');
const backToChoiceButton = document.getElementById('back-to-choice-button');
const backToMenuFinal = document.getElementById('back-to-menu-final');
const infoIcon = document.getElementById('info-icon');
const veoStudyDetails = document.getElementById('veo-study-details');
const showSection = (sectionId) => {
[choiceSection, setupSection, mainEvalContent, thankYouSection].forEach(section => {
section.style.display = section.id === sectionId ? 'block' : 'none';
});
};
const resetState = () => {
currentPairIndex = 0;
evaluationResults = [];
evaluationPairs = [];
isPreloadedStudy = false;
showSection('choice-section');
};
const startEvaluation = () => {
if (isPreloadedStudy) {
document.getElementById('study-title').textContent = 'Veo Prompt Format Evaluation';
document.getElementById('study-subtitle').textContent = 'Comparing JSON vs Plain Text prompts';
} else {
document.getElementById('study-title').textContent = 'A/B Video Evaluation';
document.getElementById('study-subtitle').textContent = 'Comparing Group A vs Group B';
}
showSection('main-eval-content');
renderQuestions();
loadVideoPair(currentPairIndex);
};
const renderQuestions = () => {
const questionsContainer = document.getElementById('questions-container');
questionsContainer.innerHTML = '';
questions.forEach(q => {
const optionsHtml = q.options.map(option => `
<label class="inline-flex items-center mr-4 mb-2">
<input type="radio" class="form-radio h-5 w-5 text-indigo-600 bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600" name="${q.id}" value="${option}" required>
<span class="ml-2 text-gray-700 dark:text-gray-300">${option}</span>
</label>
`).join('');
questionsContainer.innerHTML += `<div class="mb-4"><p class="font-semibold mb-3 text-lg text-gray-800 dark:text-gray-200">${q.text}</p><div class="flex flex-wrap justify-center">${optionsHtml}</div></div>`;
});
};
const loadVideoPair = (index) => {
if (index >= evaluationPairs.length) {
showResultsAnalysis();
return;
}
previousButton.disabled = (index === 0);
document.getElementById('progress-counter').textContent = `Pair ${index + 1} of ${evaluationPairs.length}`;
submitButton.disabled = true;
const pair = evaluationPairs[index];
const isBFirst = document.getElementById('randomize-pairs-checkbox')?.checked ? Math.random() > 0.5 : false;
const iframeA = document.getElementById('video-a');
const iframeB = document.getElementById('video-b');
const videoTagA = document.getElementById('video-a-tag');
const videoTagB = document.getElementById('video-b-tag');
let videoSrcA = isBFirst ? pair.videoB : pair.videoA;
let videoSrcB = isBFirst ? pair.videoA : pair.videoB;
let readyCount = 0;
const onReady = () => {
readyCount++;
if (readyCount === 2) {
submitButton.disabled = false;
}
};
if (isPreloadedStudy) { // YouTube Study
iframeA.style.display = 'block';
iframeB.style.display = 'block';
videoTagA.style.display = 'none';
videoTagB.style.display = 'none';
iframeA.src = `${videoSrcA}?autoplay=1&mute=1&loop=1&playlist=${videoSrcA.split('/').pop()}`;
iframeB.src = `${videoSrcB}?autoplay=1&mute=1&loop=1&playlist=${videoSrcB.split('/').pop()}`;
iframeA.onload = onReady;
iframeB.onload = onReady;
iframeA.dataset.group = isBFirst ? 'json' : 'plain';
iframeB.dataset.group = isBFirst ? 'plain' : 'json';
} else { // User-created study
iframeA.style.display = 'none';
iframeB.style.display = 'none';
videoTagA.style.display = 'block';
videoTagB.style.display = 'block';
if (typeof videoSrcA === 'string' && videoSrcA.startsWith('http')) {
videoSrcA = `/api/proxy-video?url=${encodeURIComponent(videoSrcA)}`;
}
if (typeof videoSrcB === 'string' && videoSrcB.startsWith('http')) {
videoSrcB = `/api/proxy-video?url=${encodeURIComponent(videoSrcB)}`;
}
videoTagA.src = videoSrcA;
videoTagB.src = videoSrcB;
videoTagA.oncanplay = onReady;
videoTagB.oncanplay = onReady;
videoTagA.load();
videoTagB.load();
videoTagA.dataset.group = isBFirst ? 'B' : 'A';
videoTagB.dataset.group = isBFirst ? 'A' : 'B';
}
document.querySelectorAll('input[type="radio"]').forEach(radio => { radio.checked = false; });
};
const handleSubmit = () => {
const selectedOption = document.querySelector(`input[name="${questions[0].id}"]:checked`);
if (!selectedOption) {
showToast('Please select an answer.', 'error');
return;
}
const videoTagA = document.getElementById('video-a-tag');
const iframeA = document.getElementById('video-a');
const newResult = {
pairId: currentPairIndex + 1,
assignment: {
videoA_group: isPreloadedStudy ? iframeA.dataset.group : videoTagA.dataset.group,
videoB_group: isPreloadedStudy ? document.getElementById('video-b').dataset.group : document.getElementById('video-b-tag').dataset.group
},
response: selectedOption.value
};
const resultIndex = evaluationResults.findIndex(r => r.pairId === newResult.pairId);
if (resultIndex >= 0) {
evaluationResults[resultIndex] = newResult;
} else {
evaluationResults.push(newResult);
}
currentPairIndex++;
loadVideoPair(currentPairIndex);
};
const handlePrevious = () => {
if (currentPairIndex > 0) {
currentPairIndex--;
loadVideoPair(currentPairIndex);
const previousResult = evaluationResults.find(r => r.pairId === currentPairIndex + 1);
if (previousResult) {
const radioButton = document.querySelector(`input[name="${questions[0].id}"][value="${previousResult.response}"]`);
if (radioButton) radioButton.checked = true;
}
}
};
const calculateResults = () => {
const stats = { totalPairs: evaluationResults.length, groupA_wins: 0, groupB_wins: 0, ties: 0 };
evaluationResults.forEach(result => {
if (result.response === 'Video A is higher quality') {
if (result.assignment.videoA_group === 'plain' || result.assignment.videoA_group === 'A') stats.groupA_wins++;
else stats.groupB_wins++;
} else if (result.response === 'Video B is higher quality') {
if (result.assignment.videoB_group === 'plain' || result.assignment.videoB_group === 'A') stats.groupA_wins++;
else stats.groupB_wins++;
} else {
stats.ties++;
}
});
if (isPreloadedStudy) {
stats.plain_wins = stats.groupA_wins;
stats.json_wins = stats.groupB_wins;
}
return stats;
};
const generateInterpretation = (stats) => {
if (stats.totalPairs === 0) return '<p>No results to analyze.</p>';
const tiePercent = (stats.ties / stats.totalPairs) * 100;
if (isPreloadedStudy) {
const jsonPercent = (stats.json_wins / stats.totalPairs) * 100;
const plainPercent = (stats.plain_wins / stats.totalPairs) * 100;
if (tiePercent > 50) return `<p><strong>Conclusion: The model appears robust to prompt format.</strong> Both formats produced comparable results.</p>`;
if (stats.json_wins > stats.plain_wins) return `<p><strong>Conclusion: JSON-structured prompts performed better.</strong> Videos from JSON prompts were preferred in ${jsonPercent.toFixed(1)}% of evaluations.</p>`;
if (stats.plain_wins > stats.json_wins) return `<p><strong>Conclusion: Plain text prompts performed better.</strong> Videos from plain text prompts were preferred in ${plainPercent.toFixed(1)}% of evaluations.</p>`;
return `<p><strong>Conclusion: The results were tied.</strong> Both formats performed equally well.</p>`;
} else {
const groupAPercent = (stats.groupA_wins / stats.totalPairs) * 100;
const groupBPercent = (stats.groupB_wins / stats.totalPairs) * 100;
return `<p><strong>Results:</strong> Group A was preferred ${groupAPercent.toFixed(1)}% of the time, and Group B was preferred ${groupBPercent.toFixed(1)}% of the time.</p>`;
}
};
const showResultsAnalysis = () => {
showSection('thank-you-section');
const stats = calculateResults();
const chartContainer = document.getElementById('results-chart');
chartContainer.innerHTML = '';
const data = isPreloadedStudy
? [['Format', 'Wins'], ['JSON', stats.json_wins], ['Plain Text', stats.plain_wins], ['Tie', stats.ties]]
: [['Group', 'Wins'], ['Group A', stats.groupA_wins], ['Group B', stats.groupB_wins], ['Tie', stats.ties]];
data.slice(1).forEach(row => {
const percent = stats.totalPairs > 0 ? ((row[1] / stats.totalPairs) * 100).toFixed(1) : 0;
const bar = document.createElement('div');
bar.innerHTML = `
<div class="flex justify-between mb-1">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">${row[0]}</span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">${row[1]}/${stats.totalPairs} (${percent}%)</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-6"><div class="bg-blue-600 dark:bg-blue-500 h-6 rounded-full flex items-center justify-center text-white text-xs" style="width: ${percent}%">${percent}%</div></div>
`;
bar.className = 'mb-4';
chartContainer.appendChild(bar);
});
document.getElementById('interpretation-text').innerHTML = generateInterpretation(stats);
const downloadButton = document.getElementById('download-results');
const csvContent = "pair_id,video_a_group,video_b_group,response\n" + evaluationResults.map(r =>
[r.pairId, r.assignment.videoA_group, r.assignment.videoB_group, `"${r.response}"`].join(',')
).join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
downloadButton.onclick = () => {
const a = document.createElement('a');
a.href = url;
a.download = isPreloadedStudy ? 'Veo_YouTube_Study_Results.csv' : 'AB_Eval_Results.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
showToast('Results downloaded successfully!', 'success');
};
};
const validateAndLoadCustomPairs = () => {
const groupAFiles = Array.from(document.getElementById('group-a-files').files);
const groupAUrls = document.getElementById('group-a-urls').value.split('\n').filter(url => url.trim() !== '');
const groupBFiles = Array.from(document.getElementById('group-b-files').files);
const groupBUrls = document.getElementById('group-b-urls').value.split('\n').filter(url => url.trim() !== '');
const groupAVideos = [...groupAUrls, ...groupAFiles.map(file => URL.createObjectURL(file))];
const groupBVideos = [...groupBUrls, ...groupBFiles.map(file => URL.createObjectURL(file))];
if (groupAVideos.length > 0 && groupAVideos.length === groupBVideos.length) {
startEvalButtonCreator.disabled = false;
evaluationPairs = groupAVideos.map((videoA, index) => ({ videoA, videoB: groupBVideos[index] }));
} else {
startEvalButtonCreator.disabled = true;
}
};
// --- Event Listeners ---
infoIcon.addEventListener('click', (e) => {
e.stopPropagation();
veoStudyDetails.classList.toggle('hidden');
});
participateButton.addEventListener('click', async (e) => {
if (e.target !== infoIcon && !infoIcon.contains(e.target)) {
veoStudyDetails.classList.add('hidden');
const data = await fetchVeoYouTubeStudy();
if (data && data.length > 0) {
evaluationPairs = data;
isPreloadedStudy = true;
startEvaluation();
}
}
});
createButton.addEventListener('click', () => {
veoStudyDetails.classList.add('hidden');
showSection('setup-section');
});
backButtonCreator.addEventListener('click', resetState);
backToChoiceButton.addEventListener('click', resetState);
backToMenuFinal.addEventListener('click', resetState);
document.getElementById('group-a-files').addEventListener('change', validateAndLoadCustomPairs);
document.getElementById('group-a-urls').addEventListener('input', validateAndLoadCustomPairs);
document.getElementById('group-b-files').addEventListener('change', validateAndLoadCustomPairs);
document.getElementById('group-b-urls').addEventListener('input', validateAndLoadCustomPairs);
startEvalButtonCreator.addEventListener('click', () => {
isPreloadedStudy = false; // Make sure to set this flag
startEvaluation();
});
submitButton.addEventListener('click', handleSubmit);
previousButton.addEventListener('click', handlePrevious);
resetState();
}
@@ -0,0 +1,227 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { copyToClipboard, showToast } from '../ui.js';
// --- GALLERY DATA ---
const galleryData = {
subject: {
title: "Subject, Scene & Action",
examples: [
{
title: "Complex Subject",
prompt: "A hyper-realistic, cinematic portrait of a wise, androgynous shaman of indeterminate age. Their weathered skin is etched with intricate, bioluminescent circuit-like tattoos that pulse with a soft, cyan light. They are draped in ceremonial robes woven from dark moss and shimmering, metallic fiber-optic threads. In one hand, they hold a gnarled wooden staff entwined with glowing energy conduits and topped with a floating, crystalline artifact. Perched on their shoulder is a small, mechanical owl with holographic wings and camera-lens eyes that blink with a soft, red light. Their expression is serene and ancient, eyes holding a deep, knowing look.",
youtubeId: "GKOpOcs8IF8"
},
{
title: "Portrait",
prompt: "A cinematic close-up portrait of a woman sitting in a café at night, with a very shallow depth of field. Her face is in sharp focus, while the city lights outside the window behind her are transformed into soft, beautiful bokeh circles.",
youtubeId: "Ol66pK2N7L0"
},
{
title: "Sequencing of Actions",
prompt: "A gloved hand carefully slices open the spine of an ancient, leather-bound book with a scalpel. The hand then delicately extracts a tiny, metallic data chip hidden within the binding. The character's eyes, previously focused and calm, widen in a flash of alarm as a floorboard creaks off-screen. They quickly palm the chip, their head snapping up to scan the dimly lit room, their body tense and listening for any other sound.",
youtubeId: "EbCAqMF2DBo"
},
{
title: "Emotional Expression",
prompt: "A close-up shot of a man's face, approximately 30-40 years old, with short brown hair and a beard, wearing a gray t-shirt. The man is seated at a table, looking at a document, and expressing a range of emotions, including surprise, sadness and frustration.",
youtubeId: "lYMjzZHykCo"
}
]
},
camera: {
title: "Camera Work",
examples: [
{
title: "Low Angle Shot",
prompt: "Dynamic low-angle shot of a basketball player soaring for a slam dunk, stadium lights flaring.",
youtubeId: "zCZ91E7tPeE"
},
{
title: "Drone Shot",
prompt: "Sweeping aerial drone shot flying over a tropical island chain.",
youtubeId: "gvPtt5f-kKc"
},
{
title: "Zoom In Shot",
prompt: "A slow, dramatic zoom in on a mysterious, ancient compass lying on a dusty map. The camera starts wide, showing the map and a flickering candle, then smoothly zooms in until the intricate, glowing symbols on the compass face fill the entire frame.",
youtubeId: "izn8VHHFy3c"
},
{
title: "Over-the-Shoulder",
prompt: "An over-the-shoulder shot from behind a seasoned detective, looking at a nervous informant sitting across a table in a dimly lit interrogation room. The focus is on the informant's expressive, anxious face.",
youtubeId: "z73bvXtUC_0"
},
{
title: "Rack Focus",
prompt: "A medium shot of a detective's hand in the foreground, holding a single, spent bullet casing. The camera then performs a slow rack focus, shifting from the casing to reveal the anxious face of a witness in the background, now in sharp focus.",
youtubeId: "-p6W4mCYuvc"
},
{
title: "Handheld Camera",
prompt: "An intense handheld camera shot during a chaotic marketplace chase. The camera struggles to keep up, with jerky movements and quick, unstable pans as it follows the character weaving through dense crowds and knocking over stalls.",
youtubeId: "csnE4FNogJQ"
}
]
},
style: {
title: "Visual & Temporal Styles",
examples: [
{
title: "Anime Style",
prompt: "A dynamic scene in a vibrant Japanese anime style. A magical girl with silver hair and glowing blue eyes walks in a forest The style features sharp lines, bright, saturated colors, and expressive.",
youtubeId: "vu2ZFw-9ZMI"
},
{
title: "Lens Flare",
prompt: "A cinematic shot of a couple embracing on a beach at sunset. As the sun dips below the horizon behind them, a warm, anamorphic lens flare streaks horizontally across the frame, adding a romantic and nostalgic feeling to the scene.",
youtubeId: "jY3gQS73614"
},
{
title: "Jump Cut",
prompt: "A person sitting in the same position but wearing different outfits, with sharp jump cuts between each outfit change. The background should stay static and the person should reappear instantly in the new outfit, creating a fast-paced, rhythmic jump cut effect. The lighting and framing should remain consistent to emphasize the sudden changes.",
youtubeId: "d-cGj3kAnsQ"
},
{
title: "Time-Lapse",
prompt: "A time-lapse of a bustling city skyline as day transitions to night. The camera is static. Watch as the sun sets, casting long shadows, and the city lights begin to twinkle on, with streaks of car headlights moving along the streets below.",
youtubeId: "CT0PIze9w0Y"
},
{
title: "Cyberpunk Lighting",
prompt: "A hyper-realistic, cinematic shot of a rain-slicked cyberpunk alleyway at midnight. Pulsating pink and teal neon signs reflect off puddles on the ground, illuminating the steam rising from a street vendor's cart.",
youtubeId: "bKIZ-pdCJnA"
},
{
title: "Vintage Style",
prompt: "A vintage 1920s street scene, sepia toned, film grain, with characters in period attire.",
youtubeId: "WJwj6y7p8SI"
}
]
},
audio: {
title: "Audio",
examples: [
{
title: "Ambient Noise",
prompt: "A static, wide shot of a vast, ancient library at night. The only sounds are the soft, rhythmic ticking of a grandfather clock, the gentle rustle of turning pages, and the faint sound of wind howling outside the tall, arched windows.",
youtubeId: "WsXXBDhO7l0"
},
{
title: "Dialogue",
prompt: "A medium shot in a dimly lit interrogation room. The seasoned detective says: Your story has holes. The nervous informant, sweating under a single bare bulb, replies: I'm telling you everything I know. The only other sounds are the slow, rhythmic ticking of a wall clock and the faint sound of rain against the window.",
youtubeId: "yGcMvkFK9Zo"
}
]
}
};
export async function getGalleryContent() {
try {
const response = await fetch('/src/features/templates/gallery.html');
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return await response.text();
} catch (error) {
console.error('Failed to fetch gallery template:', error);
return '<div>Error loading content. Please check the console for details.</div>';
}
}
export function initGallery() {
window.copyToClipboard = copyToClipboard;
const catContainer = document.getElementById('gallery-categories');
const slidersContainer = document.getElementById('gallery-sliders-container');
catContainer.innerHTML = '';
slidersContainer.innerHTML = '';
Object.keys(galleryData).forEach(key => {
const button = document.createElement('button');
button.className = 'gallery-category-button py-2 px-5 rounded-full font-semibold shadow-sm bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 transition-colors duration-200';
button.textContent = galleryData[key].title;
button.onclick = () => showGalleryCategory(key);
catContainer.appendChild(button);
slidersContainer.appendChild(createSlider(key, galleryData[key]));
});
showGalleryCategory('subject'); // Show the first category by default
}
function createSlider(categoryId, categoryData) {
const sliderWrapper = document.createElement('div');
sliderWrapper.id = `gallery-${categoryId}`;
sliderWrapper.className = 'gallery-examples relative';
sliderWrapper.innerHTML = `
<div class="slider-container flex overflow-x-auto snap-x snap-mandatory gap-6 pb-4">
${categoryData.examples.map((ex, index) => {
const promptId = `gallery-prompt-${categoryId}-${index}`;
// Direct YouTube embed URL, autoplay=0 (no auto-play), mute=1 (no sound), controls=1
const youtubeEmbedUrl = ex.youtubeId ?
`https://www.youtube.com/embed/${ex.youtubeId}?autoplay=0&mute=1&controls=1&modestbranding=1&rel=0` :
''; // Empty if no YouTube ID
return `
<div class="slider-item snap-start flex-shrink-0 w-full sm:w-1/2 lg:w-1/3 bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden flex flex-col">
<div class="video-wrapper">
${youtubeEmbedUrl ? `
<iframe
src="${youtubeEmbedUrl}"
title="${ex.title}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
` : `
<div class="flex items-center justify-center h-full text-gray-500 dark:text-gray-400">
No video available
</div>
`}
</div>
<div class="p-6 flex-grow flex flex-col">
<h4 class="font-semibold text-lg mb-2 text-gray-800 dark:text-gray-200">${ex.title}</h4>
<p id="${promptId}" class="text-gray-700 dark:text-gray-300 text-sm flex-grow mb-4" style="text-align: justify;">${ex.prompt}</p>
<div class="flex gap-2">
<button onclick="copyToClipboard('${promptId}')" class="flex items-center gap-2 text-sm py-2 px-3 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-md transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
Copy Prompt
</button>
</div>
</div>
</div>`;
}).join('')}
</div>`;
return sliderWrapper;
}
function showGalleryCategory(category) {
document.querySelectorAll('.gallery-examples').forEach(el => el.style.display = 'none');
document.querySelectorAll('.gallery-category-button').forEach(el => {
el.classList.remove('bg-indigo-600', 'hover:bg-indigo-700', 'text-white');
el.classList.add('bg-gray-200', 'hover:bg-gray-300', 'dark:bg-gray-700', 'dark:hover:bg-gray-600', 'text-gray-700', 'dark:text-gray-300');
});
const activeBtn = Array.from(document.querySelectorAll('.gallery-category-button')).find(btn => btn.textContent === galleryData[category].title);
if (activeBtn) {
document.getElementById(`gallery-${category}`).style.display = 'block';
activeBtn.classList.remove('bg-gray-200', 'hover:bg-gray-300', 'dark:bg-gray-700', 'dark:hover:bg-gray-600', 'text-gray-700', 'dark:text-gray-300');
activeBtn.classList.add('bg-indigo-600', 'hover:bg-indigo-700', 'text-white');
}
}
@@ -0,0 +1,420 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { callGeminiApi } from '../api.js';
import { showToast, copyToClipboard } from '../ui.js';
// --- DATA FOR THE FORM ---
const textVideoSelectData = {
camera_angle: { label: "Camera Angles", options: ["Eye-Level Shot", "Low-Angle Shot", "High-Angle Shot", "Bird's-Eye View", "Top-Down Shot", "Worm's-Eye View", "Dutch Angle", "Canted Angle", "Close-Up", "Extreme Close-Up", "Medium Shot", "Full Shot", "Long Shot", "Wide Shot", "Establishing Shot", "Over-the-Shoulder Shot", "Point-of-View (POV) Shot"] },
camera_movement: { label: "Camera Movements", options: ["Static Shot (or fixed)", "Pan (left)", "Pan (right)", "Tilt (up)", "Tilt (down)", "Dolly (In)", "Dolly (Out)", "Zoom (In)", "Zoom (Out)", "Truck (Left)", "Truck (Right)", "Pedestal (Up)", "Pedestal (Down)", "Crane Shot", "Aerial Shot", "Drone Shot", "Handheld", "Shaky Cam", "Whip Pan", "Arc Shot"] },
lens_effect: { label: "Lens & Optical Effects", options: ["Wide-Angle Lens (e.g., 24mm)", "Telephoto Lens (e.g., 85mm)", "Shallow Depth of Field", "Bokeh", "Deep Depth of Field", "Lens Flare", "Rack Focus", "Fisheye Lens Effect", "Vertigo Effect (Dolly Zoom)"] },
visual_style: { label: "Visual Style & Aesthetics", options: ["Photorealistic", "Cinematic", "Vintage", "Japanese anime style", "Claymation style", "Stop-motion animation", "In the style of Van Gogh", "Surrealist painting", "Monochromatic black and white", "Vibrant and saturated", "Film noir style", "High-key lighting", "Low-key lighting", "Golden hour glow", "Volumetric lighting", "Backlighting to create a silhouette"] },
temporal_element: { label: "Temporal Elements", options: ["Slow-motion", "Fast-paced action", "Time-lapse", "Hyperlapse", "Pulsating light", "Rhythmic movement"] },
sound_effects: { label: "Sound Effects & Ambience", options: ["Sound of a phone ringing", "Water splashing", "Soft house sounds", "Ticking clock", "City traffic and sirens", "Waves crashing", "Quiet office hum"] }
};
// --- HELPER FUNCTIONS TO BUILD THE FORM ---
function createInputComponent(id, labelText, placeholder) {
const container = document.createElement('div');
const label = document.createElement('label');
label.htmlFor = id;
label.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
label.textContent = labelText;
const input = document.createElement('input');
input.type = 'text';
input.id = id;
input.autocomplete = 'off';
input.className = 'w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100';
input.placeholder = placeholder;
container.appendChild(label);
container.appendChild(input);
return container;
}
function createSelectComponent(id, data) {
const container = document.createElement('div');
const label = document.createElement('label');
label.htmlFor = id;
label.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
label.textContent = data.label;
const select = document.createElement('select');
select.id = id;
select.className = 'w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100';
select.innerHTML = `<option value="">-- None --</option><option value="custom">Custom...</option>` + data.options.map(o => `<option value="${o}">${o}</option>`).join('');
const customInput = document.createElement('input');
customInput.type = 'text';
customInput.id = `${id}-custom`;
customInput.className = 'custom-input w-full p-2 mt-2 border border-gray-300 dark:border-gray-600 rounded-md hidden bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100';
customInput.placeholder = 'Enter custom value...';
select.onchange = () => {
customInput.style.display = select.value === 'custom' ? 'block' : 'none';
};
container.appendChild(label);
container.appendChild(select);
container.appendChild(customInput);
return container;
}
function populateTextToVideoForm() {
const container = document.querySelector('#text-to-video-content .grid');
if (!container) return;
container.innerHTML = '';
const elements = [
{ type: 'input', id: 'subject-input', label: 'Subject', placeholder: 'e.g., A dog' },
{ type: 'input', id: 'action-input', label: 'Action', placeholder: 'e.g., running' },
{ type: 'input', id: 'scene-input', label: 'Scene / Context', placeholder: 'e.g., on a sunny beach' },
...Object.keys(textVideoSelectData).map(key => ({ type: 'select', id: key, data: textVideoSelectData[key] })),
{ type: 'input', id: 'dialogue-input', label: 'Dialogue', placeholder: `e.g., Let's go!` }
];
elements.forEach(el => {
if (el.type === 'input') container.appendChild(createInputComponent(el.id, el.label, el.placeholder));
else if (el.type === 'select') container.appendChild(createSelectComponent(el.id, el.data));
});
}
function populateImageToVideoForm() {
const container = document.getElementById('image-to-video-fields');
if (!container) return;
container.innerHTML = '';
const elements = [
{ type: 'input', id: 'image-action-input', label: 'Action', placeholder: 'e.g., snow falling gently' },
{ type: 'input', id: 'image-scene-input', label: 'Scene / Context', placeholder: 'e.g., steam rising from a coffee cup' },
...Object.keys(textVideoSelectData).map(key => ({ type: 'select', id: `image-${key}`, data: textVideoSelectData[key] })),
{ type: 'input', id: 'image-dialogue-input', label: 'Dialogue', placeholder: `e.g., a character sighs` }
];
elements.forEach(el => {
if (el.type === 'input') container.appendChild(createInputComponent(el.id, el.label, el.placeholder));
else if (el.type === 'select') container.appendChild(createSelectComponent(el.id, el.data));
});
}
export async function getGeneratorContent() {
try {
const response = await fetch('/src/features/templates/generator.html');
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return await response.text();
} catch (error) {
console.error('Failed to fetch generator template:', error);
return '<div>Error loading content. Please check the console for details.</div>';
}
}
async function generateLongerPrompt(type) {
const isText = type === 'text';
const shortPromptElement = document.getElementById(isText ? 'text-prompt-output' : 'image-prompt-output');
const longPromptSection = document.getElementById(isText ? 'text-long-prompt-section' : 'image-long-prompt-section');
const longPromptOutputElement = document.getElementById(isText ? 'text-long-prompt-output' : 'image-long-prompt-output');
const generateBtn = document.getElementById(isText ? 'generate-longer-text-prompt-btn' : 'generate-longer-image-prompt-btn');
const initialPrompt = shortPromptElement.textContent;
if (!initialPrompt) {
showToast("Initial prompt is missing.", 'error');
return;
}
const originalButtonHtml = generateBtn.innerHTML;
generateBtn.disabled = true;
generateBtn.innerHTML = `
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Enhancing...
`;
longPromptSection.classList.remove('hidden');
longPromptOutputElement.textContent = "Enhancing prompt with more cinematic detail...";
const systemPrompt = `You are a Google Veo3 prompt engineer. Enhance this video prompt to be more cinematic and detailed: "${initialPrompt}". Output ONLY the enhanced video prompt text - no analysis, no markdown, no asterisks, no brackets, no explanations. Just the pure enhanced prompt text that will be sent to Veo3.`;
try {
const result = await callGeminiApi(systemPrompt);
longPromptOutputElement.textContent = result;
generateBtn.classList.add('hidden');
} catch (error) {
showToast(error.message, 'error');
longPromptOutputElement.textContent = `Error: ${error.message}`;
} finally {
if (!generateBtn.classList.contains('hidden')) {
generateBtn.disabled = false;
generateBtn.innerHTML = originalButtonHtml;
}
}
}
function clearForm(type) {
const prefix = type === 'image' ? 'image-' : '';
const inputs = document.querySelectorAll(`#${type}-to-video-content input[type="text"], #${type}-to-video-content textarea`);
inputs.forEach(input => input.value = '');
const selects = document.querySelectorAll(`#${type}-to-video-content select`);
selects.forEach(select => {
select.selectedIndex = 0;
const customInput = document.getElementById(`${select.id}-custom`);
if (customInput) {
customInput.style.display = 'none';
}
});
if (type === 'image') {
const imageUpload = document.getElementById('image-upload');
const imagePreviewContainer = document.getElementById('image-preview-container');
if(imageUpload) imageUpload.value = '';
if(imagePreviewContainer) imagePreviewContainer.classList.add('hidden');
document.getElementById('image-prompt-output-container').classList.add('hidden');
document.getElementById('image-long-prompt-section').classList.add('hidden');
} else {
document.getElementById('text-prompt-output-container').classList.add('hidden');
document.getElementById('text-long-prompt-section').classList.add('hidden');
}
showToast(`${type.charAt(0).toUpperCase() + type.slice(1)} form cleared!`, 'info');
}
export function initGenerator() {
window.copyToClipboard = copyToClipboard;
populateTextToVideoForm();
populateImageToVideoForm();
const textTab = document.getElementById('text-to-video-tab');
const imageTab = document.getElementById('image-to-video-tab');
const textContent = document.getElementById('text-to-video-content');
const imageContent = document.getElementById('image-to-video-content');
const generateTextBtn = document.getElementById('generate-text-prompt-btn');
const generateLongerTextBtn = document.getElementById('generate-longer-text-prompt-btn');
const generateImageBtn = document.getElementById('generate-image-prompt-btn');
const generateLongerImageBtn = document.getElementById('generate-longer-image-prompt-btn');
const imageUpload = document.getElementById('image-upload');
const imagePreviewContainer = document.getElementById('image-preview-container');
const imagePreview = document.getElementById('image-preview');
const clearTextFormBtn = document.getElementById('clear-text-form-btn');
const clearImageFormBtn = document.getElementById('clear-image-form-btn');
textTab.addEventListener('click', () => {
textContent.classList.remove('hidden');
imageContent.classList.add('hidden');
textTab.classList.add('text-indigo-600', 'dark:text-indigo-400', 'border-indigo-500');
textTab.classList.remove('text-gray-500', 'dark:text-gray-400', 'border-transparent');
imageTab.classList.add('text-gray-500', 'dark:text-gray-400', 'border-transparent');
imageTab.classList.remove('text-indigo-600', 'dark:text-indigo-400', 'border-indigo-500');
});
imageTab.addEventListener('click', () => {
imageContent.classList.remove('hidden');
textContent.classList.add('hidden');
imageTab.classList.add('text-indigo-600', 'dark:text-indigo-400', 'border-indigo-500');
imageTab.classList.remove('text-gray-500', 'dark:text-gray-400', 'border-transparent');
textTab.classList.add('text-gray-500', 'dark:text-gray-400', 'border-transparent');
textTab.classList.remove('text-indigo-600', 'dark:text-indigo-400', 'border-indigo-500');
});
imageUpload.addEventListener('change', () => {
const file = imageUpload.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
imagePreview.src = e.target.result;
imagePreviewContainer.classList.remove('hidden');
};
reader.readAsDataURL(file);
} else {
imagePreviewContainer.classList.add('hidden');
}
});
// TEXT TO VIDEO GENERATION
generateTextBtn.addEventListener('click', async () => {
const keywords = [];
['subject-input', 'action-input', 'scene-input', 'dialogue-input'].forEach(id => {
const element = document.getElementById(id);
if (element && element.value) {
keywords.push(id === 'dialogue-input' ? `A character says: '${element.value}'` : element.value);
}
});
Object.keys(textVideoSelectData).forEach(key => {
const select = document.getElementById(key);
if (!select) return;
let value = select.value;
if (value === 'custom') {
const customInput = document.getElementById(`${key}-custom`);
value = customInput ? customInput.value : '';
}
if (value) keywords.push(value);
});
if (keywords.length === 0) {
showToast("Please provide at least one keyword.", 'error');
return;
}
// PROMPT for Veo3
const systemPrompt = `You are a Google Veo3 prompt engineer. Create a cinematic video prompt using these keywords: [${keywords.join(', ')}]. Synthesize them into a single cohesive video instruction. Output ONLY the video prompt text - no analysis, no markdown, no asterisks, no explanations. Just the pure prompt text that will be sent to Veo3.`;
const outputContainer = document.getElementById('text-prompt-output-container');
const outputElement = document.getElementById('text-prompt-output');
const originalButtonHtml = generateTextBtn.innerHTML;
generateTextBtn.disabled = true;
generateTextBtn.innerHTML = `
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Generating...
`;
outputContainer.classList.remove('hidden');
document.getElementById('text-long-prompt-section').classList.add('hidden');
generateLongerTextBtn.classList.add('hidden');
outputElement.textContent = 'Processing...';
try {
const result = await callGeminiApi(systemPrompt);
outputElement.textContent = result;
if (result && !result.toLowerCase().startsWith('error')) {
generateLongerTextBtn.innerHTML = 'Generate Longer Prompt';
generateLongerTextBtn.classList.remove('hidden');
}
} catch (error) {
showToast(error.message, 'error');
outputElement.textContent = `Error: ${error.message}`;
} finally {
generateTextBtn.disabled = false;
generateTextBtn.innerHTML = originalButtonHtml;
}
});
// IMAGE TO VIDEO GENERATION
generateImageBtn.addEventListener('click', async () => {
const imageFile = imageUpload.files[0];
if (!imageFile) {
showToast("Please upload an image first.", 'error');
return;
}
// Read the file and ensure we get proper base64 data
const reader = new FileReader();
reader.onload = async (e) => {
try {
// Get the data URL and extract base64
const dataUrl = e.target.result;
const base64Data = dataUrl.split(',')[1];
// Verify we have valid base64 data
if (!base64Data || typeof base64Data !== 'string') {
showToast("Failed to read image data", 'error');
console.error('Invalid base64 data:', typeof base64Data);
return;
}
console.log('Image loaded successfully:', {
fileType: imageFile.type,
fileSize: imageFile.size,
base64Length: base64Data.length
});
// Create the image part with proper structure
const imagePart = {
inlineData: {
mimeType: imageFile.type,
data: base64Data
}
};
// Collect keywords
const keywords = [];
['image-action-input', 'image-scene-input', 'image-dialogue-input'].forEach(id => {
const element = document.getElementById(id);
if (element && element.value) {
keywords.push(id === 'image-dialogue-input' ? `A character says: '${element.value}'` : element.value);
}
});
Object.keys(textVideoSelectData).forEach(key => {
const select = document.getElementById(`image-${key}`);
if (!select) return;
let value = select.value;
if (value === 'custom') {
const customInput = document.getElementById(`image-${key}-custom`);
value = customInput ? customInput.value : '';
}
if (value) keywords.push(value);
});
// PROMPT for Veo3
const systemPrompt = keywords.length > 0
? `You are a Google Veo3 prompt engineer. Based on the uploaded image, create a cinematic video prompt that brings this exact scene to life with motion, incorporating these keywords: [${keywords.join(', ')}]. Output ONLY the video prompt text - no analysis, no markdown, no asterisks, no explanations. Just the pure prompt text that will be sent to Veo3.`
: `You are a Google Veo3 prompt engineer. Based on the uploaded image, create a cinematic video prompt that brings this exact scene to life with motion and animation. Output ONLY the video prompt text - no analysis, no markdown, no asterisks, no explanations. Just the pure prompt text that will be sent to Veo3.`;
const outputContainer = document.getElementById('image-prompt-output-container');
const outputElement = document.getElementById('image-prompt-output');
const originalButtonHtml = generateImageBtn.innerHTML;
generateImageBtn.disabled = true;
generateImageBtn.innerHTML = `
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Generating...
`;
outputContainer.classList.remove('hidden');
document.getElementById('image-long-prompt-section').classList.add('hidden');
generateLongerImageBtn.classList.add('hidden');
outputElement.textContent = 'Processing image...';
try {
// Call the API with the image
const result = await callGeminiApi(systemPrompt, [imagePart]);
outputElement.textContent = result;
if (result && !result.toLowerCase().startsWith('error')) {
generateLongerImageBtn.innerHTML = 'Generate Longer Prompt';
generateLongerImageBtn.disabled = false;
generateLongerImageBtn.classList.remove('hidden');
}
} catch (error) {
showToast(error.message, 'error');
outputElement.textContent = `Error: ${error.message}`;
} finally {
generateImageBtn.disabled = false;
generateImageBtn.innerHTML = originalButtonHtml;
}
} catch (error) {
console.error('Error processing image:', error);
showToast('Failed to process image', 'error');
generateImageBtn.disabled = false;
generateImageBtn.innerHTML = originalButtonHtml;
}
};
reader.onerror = () => {
showToast('Failed to read image file', 'error');
console.error('FileReader error');
};
// Start reading the file
reader.readAsDataURL(imageFile);
});
generateLongerTextBtn.addEventListener('click', () => generateLongerPrompt('text'));
generateLongerImageBtn.addEventListener('click', () => generateLongerPrompt('image'));
clearTextFormBtn.addEventListener('click', () => clearForm('text'));
clearImageFormBtn.addEventListener('click', () => clearForm('image'));
}
@@ -0,0 +1,101 @@
<div id="alignment-eval-content">
<section id="alignment-eval">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-6 md:p-8">
<h3 class="text-xl font-bold text-gray-800 dark:text-gray-200 mb-4">Prompt-Video Alignment Evaluation</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">Streamlined 2-step process: Load inputs to automatically create pairs, then start evaluation.</p>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 mb-6 border border-gray-200 dark:border-gray-600">
<h4 class="font-semibold text-gray-900 dark:text-gray-200 mb-3">Step 1: Load Prompts and Videos (Auto-Pairing)</h4>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Prompts (Upload .txt/.csv or Paste Semicolon-Separated List)</label>
<input type="file" accept=".csv,.txt" id="prompts-csv-input" class="w-full text-sm text-gray-600 dark:text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-white dark:file:bg-gray-600 hover:file:bg-gray-100 dark:hover:file:bg-gray-500 file:text-gray-700 dark:file:text-gray-200 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800">
<textarea id="prompts-text-input" rows="2" class="mt-2 w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" placeholder="A lion roaring; A bird flying..."></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Videos (Select local files OR paste URLs, one per line)</label>
<input type="file" accept="video/*" multiple id="batch-videos-input" class="w-full text-sm text-gray-600 dark:text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-white dark:file:bg-gray-600 hover:file:bg-gray-100 dark:hover:file:bg-gray-500 file:text-gray-700 dark:file:text-gray-200 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800">
<p class="text-center text-xs text-gray-500 dark:text-gray-400 my-2 font-semibold">OR</p>
<textarea id="videos-url-input" rows="2" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" placeholder="https://storage.googleapis.com/bucket/video1.mp4&#10;https://example.com/video2.webm..."></textarea>
</div>
<div class="flex gap-4 pt-2">
<button id="load-and-pair-btn" class="flex-1 bg-indigo-600 text-white py-2 px-4 rounded-lg font-semibold hover:bg-indigo-700">Load & Create Pairs</button>
<button id="clear-all-btn" class="flex-1 bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200 py-2 px-4 rounded-lg font-semibold hover:bg-gray-300 dark:hover:bg-gray-500">Clear All</button>
</div>
</div>
</div>
<!-- Optional reordering (only shown for multiple pairs) -->
<div id="reorder-section" class="hidden">
<div class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg p-4 mb-6">
<div class="flex items-center gap-2 text-blue-800 dark:text-blue-200 mb-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<h4 class="font-semibold">Optional: Reorder Before Evaluation</h4>
</div>
<p class="text-sm text-blue-700 dark:text-blue-300">Pairs have been created automatically. Drag items below to reorder if needed. The first prompt pairs with the first video, and so on.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<!-- Prompts column -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600 p-4">
<h5 class="font-semibold text-gray-900 dark:text-gray-200 mb-3 flex items-center gap-2">
Prompts
<span class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"></path>
</svg>
Drag to reorder
</span>
</h5>
<div id="prompts-list" class="space-y-2 min-h-[100px]"></div>
</div>
<!-- Videos column -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-600 p-4">
<h5 class="font-semibold text-gray-900 dark:text-gray-200 mb-3 flex items-center gap-2">
Videos
<span class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"></path>
</svg>
Drag to reorder
</span>
</h5>
<div id="videos-list" class="space-y-2 min-h-[100px]"></div>
</div>
</div>
</div>
<!-- Pairs preview -->
<div id="alignment-pairs-container" class="space-y-2"></div>
<!-- Step 2: Start evaluation -->
<div id="eval-actions-container" class="mt-8 border-t border-gray-200 dark:border-gray-700 pt-6 hidden">
<div class="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-lg p-4 mb-4">
<div class="flex items-center gap-2 text-green-800 dark:text-green-200 mb-1">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<h4 class="font-semibold">Step 2: Start Evaluation</h4>
</div>
<p class="text-sm text-green-700 dark:text-green-300">Video pairs loaded. Start evaluation to assess how well each video matches its prompt.</p>
</div>
<button id="start-alignment-eval-btn" class="w-full flex items-center justify-center bg-cyan-600 text-white py-3 px-4 rounded-lg font-bold hover:bg-cyan-700">
Start Evaluation
</button>
</div>
<div id="alignment-results-container" class="mt-8 space-y-6 hidden"></div>
<div id="download-alignment-results-container" class="mt-8 text-center hidden">
<button id="download-alignment-results-btn" class="bg-blue-600 text-white font-bold py-3 px-8 rounded-lg hover:bg-blue-700">
Download Detailed Results (CSV)
</button>
</div>
</div>
</section>
</div>
@@ -0,0 +1,42 @@
<div id="converter-content">
<section id="converter">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-6 md:p-8">
<h3 class="text-xl font-bold text-gray-800 dark:text-gray-200 mb-4">Prompt Format Converter</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">Convert a prompt between plain text, JSON, YAML, or XML.</p>
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div class="md:col-span-4">
<label for="converter-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Input Prompt (any format)</label>
<textarea id="converter-input" rows="6" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" placeholder="Paste your prompt here... e.g., plain text, JSON, XML, or YAML"></textarea>
</div>
<div>
<label for="format-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Convert To</label>
<select id="format-select" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
<option>JSON</option>
<option>YAML</option>
<option>XML</option>
<option>Plain Text</option>
</select>
</div>
</div>
<div class="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<button id="convert-format-btn" class="w-full flex items-center justify-center bg-teal-600 text-white py-3 px-4 rounded-lg font-bold hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-teal-500 disabled:bg-teal-400 transition-all">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-2" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M15.28 4.72a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 000 1.06l4.25 4.25a.75.75 0 11-1.06 1.06l-4.25-4.25a2.25 2.25 0 010-3.18l4.25-4.25a.75.75 0 011.06 0zm-8.56 0a.75.75 0 011.06 0l4.25 4.25a2.25 2.25 0 010 3.18l-4.25 4.25a.75.75 0 11-1.06-1.06l4.25-4.25a.75.75 0 000-1.06L4.72 5.78a.75.75 0 010-1.06z" clip-rule="evenodd" /></svg>
Convert Format
</button>
<button id="clear-converter-btn" class="w-full flex items-center justify-center bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200 py-3 px-4 rounded-lg font-bold hover:bg-gray-300 dark:hover:bg-gray-500">
Clear All
</button>
</div>
<div id="converter-output-container" class="mt-6 p-4 bg-gray-900 dark:bg-gray-950 text-white dark:text-gray-100 rounded-lg border border-gray-700 dark:border-gray-600" style="display: none;">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold text-teal-300 dark:text-teal-400">Converted Output:</h4>
<button onclick="copyToClipboard('converter-output')" class="p-1 text-gray-300 dark:text-gray-400 hover:text-teal-300 dark:hover:text-teal-400 rounded-md" title="Copy Output">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
</div>
<pre><code id="converter-output" class="text-sm text-gray-100 dark:text-gray-200"></code></pre>
</div>
</div>
</section>
</div>
@@ -0,0 +1,30 @@
<div id="enhancer-content">
<section id="enhancer">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-6 md:p-8">
<h3 class="text-xl font-bold text-gray-800 dark:text-gray-200 mb-4">Prompt Enhancer</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">Paste your existing prompt below and let Gemini make it more cinematic and descriptive.</p>
<div>
<label for="enhancer-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Your Prompt</label>
<textarea id="enhancer-input" rows="4" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" placeholder="e.g., A cat sitting on a couch."></textarea>
</div>
<div class="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<button id="enhance-prompt-btn" class="w-full flex items-center justify-center bg-purple-600 text-white py-3 px-4 rounded-lg font-bold hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:bg-purple-400 transition-all">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-2" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zM12 2a1 1 0 01.967.744L14.146 7.2 17.5 9.134a1 1 0 010 1.732l-3.354 1.935-1.18 4.455a1 1 0 01-1.933 0L9.854 12.8l-3.354-1.935a1 1 0 010-1.732L10.146 7.2A1 1 0 0112 2z" clip-rule="evenodd" /></svg>
Enhance Prompt
</button>
<button id="clear-enhancer-btn" class="w-full flex items-center justify-center bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200 py-3 px-4 rounded-lg font-bold hover:bg-gray-300 dark:hover:bg-gray-500">
Clear All
</button>
</div>
<div id="enhancer-output-container" class="mt-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 hidden">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold text-purple-700 dark:text-purple-400">Enhanced Prompt:</h4>
<button onclick="window.copyToClipboard('enhancer-output')" class="p-1 text-gray-500 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 rounded-md" title="Copy Enhanced Prompt">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
</div>
<p id="enhancer-output" class="text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 p-3 rounded-md whitespace-pre-wrap border border-gray-200 dark:border-gray-600"></p>
</div>
</div>
</section>
</div>
@@ -0,0 +1,98 @@
<div id="eval-content">
<div id="choice-section" class="max-w-lg mx-auto bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md text-center">
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-200">A/B Video Evaluation</h1>
<p class="text-gray-600 dark:text-gray-400 mt-4 mb-6">Participate in existing studies or create your own.</p>
<div class="space-y-4">
<div class="relative">
<button id="participate-button" class="w-full bg-green-600 text-white font-bold py-3 px-8 rounded-lg hover:bg-green-700 flex items-center justify-center">
<span>Participate in Veo Prompt Format Study</span>
<svg id="info-icon" class="ml-2 w-5 h-5 cursor-pointer" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</button>
<div id="veo-study-details" class="hidden mt-2 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg p-4 text-left">
<h3 class="font-semibold text-blue-900 dark:text-blue-200 mb-2">About the Veo Study</h3>
<p class="text-sm text-blue-800 dark:text-blue-300">This study evaluates the impact of prompt format on video quality. You'll compare videos generated using <strong>JSON-structured prompts</strong> versus <strong>plain text prompts</strong>.</p>
</div>
</div>
<button id="create-button" class="w-full bg-indigo-600 text-white font-bold py-3 px-8 rounded-lg hover:bg-indigo-700">Create Your Own Study</button>
</div>
</div>
<div id="setup-section" class="hidden max-w-2xl mx-auto bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md">
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-200 text-center">Create Your Own Study</h1>
<p class="text-gray-600 dark:text-gray-400 mt-4 mb-6 text-center">Upload local files or paste public URLs. Both groups must have the same number of videos.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Group A Videos</label>
<input type="file" id="group-a-files" multiple accept="video/*" class="w-full text-sm text-gray-500 dark:text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-white dark:file:bg-gray-600 hover:file:bg-gray-100 dark:hover:file:bg-gray-500 file:text-gray-700 dark:file:text-gray-200 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700">
<textarea id="group-a-urls" rows="4" class="mt-2 w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" placeholder="Or paste URLs (one per line)"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Group B Videos</label>
<input type="file" id="group-b-files" multiple accept="video/*" class="w-full text-sm text-gray-500 dark:text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-white dark:file:bg-gray-600 hover:file:bg-gray-100 dark:hover:file:bg-gray-500 file:text-gray-700 dark:file:text-gray-200 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700">
<textarea id="group-b-urls" rows="4" class="mt-2 w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" placeholder="Or paste URLs (one per line)"></textarea>
</div>
</div>
<div class="mt-6 flex items-center justify-center">
<label class="flex items-center">
<input type="checkbox" id="randomize-pairs-checkbox" class="h-4 w-4 text-indigo-600 border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Randomize video pairs</span>
</label>
</div>
<div class="mt-8 grid grid-cols-2 gap-4">
<button id="back-button-creator" class="w-full bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200 font-bold py-3 px-4 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-500">Back</button>
<button id="start-eval-button-creator" class="w-full bg-indigo-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-indigo-700 disabled:bg-indigo-400" disabled>Start Evaluation</button>
</div>
</div>
<div id="main-eval-content" class="hidden">
<header class="text-center mb-8 relative">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-200" id="study-title">A/B Video Evaluation</h1>
<p id="study-subtitle" class="text-gray-600 dark:text-gray-400 mt-2"></p>
</header>
<main>
<p id="progress-counter" class="text-lg font-semibold text-center mb-4 text-gray-800 dark:text-gray-200"></p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<div>
<h2 class="text-xl font-semibold mb-4 text-center text-gray-800 dark:text-gray-200">Video A</h2>
<div class="video-container bg-black rounded-md">
<iframe id="video-a" class="w-full h-full" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
<video id="video-a-tag" controls playsinline class="w-full h-full bg-black rounded-md hidden"></video>
</div>
</div>
<div>
<h2 class="text-xl font-semibold mb-4 text-center text-gray-800 dark:text-gray-200">Video B</h2>
<div class="video-container bg-black rounded-md">
<iframe id="video-b" class="w-full h-full" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
<video id="video-b-tag" controls playsinline class="w-full h-full bg-black rounded-md hidden"></video>
</div>
</div>
</div>
<div id="questions-container" class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md border border-gray-200 dark:border-gray-700"></div>
<div class="mt-8 text-center space-x-4">
<button id="previous-pair" class="bg-gray-500 text-white font-bold py-3 px-6 rounded-lg hover:bg-gray-600 disabled:bg-gray-300 disabled:text-gray-500" disabled>Previous</button>
<button id="submit-vote" class="bg-indigo-600 text-white font-bold py-3 px-8 rounded-lg hover:bg-indigo-700 disabled:bg-gray-400">Submit & Next</button>
</div>
<div class="mt-4 text-center">
<button id="back-to-choice-button" class="text-sm text-gray-600 dark:text-gray-400 hover:underline"> &larr; Back to Main Menu</button>
</div>
</main>
</div>
<div id="thank-you-section" class="hidden max-w-3xl mx-auto">
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md">
<h2 class="text-3xl font-bold text-green-600 dark:text-green-400 text-center">Evaluation Complete!</h2>
<div id="results-summary" class="mt-8">
<h3 class="text-xl font-semibold mb-4 text-gray-800 dark:text-gray-200">Your Results</h3>
<div id="results-chart" class="bg-gray-50 dark:bg-gray-700 p-6 rounded-lg border border-gray-200 dark:border-gray-600"></div>
</div>
<div id="results-interpretation" class="mt-6 bg-blue-50 dark:bg-blue-900 p-6 rounded-lg border border-blue-200 dark:border-blue-700">
<h4 class="font-semibold text-blue-900 dark:text-blue-200 mb-2">Analysis</h4>
<div id="interpretation-text" class="text-sm text-blue-800 dark:text-blue-300"></div>
</div>
<div class="mt-8 flex flex-col sm:flex-row gap-4 justify-center">
<button id="download-results" class="bg-blue-600 text-white font-bold py-3 px-6 rounded-lg hover:bg-blue-700">Download Results (CSV)</button>
<button id="back-to-menu-final" class="bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200 font-bold py-3 px-6 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-500">Back to Main Menu</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,47 @@
<div id="gallery-content">
<section id="gallery" class="mb-12">
<h2 class="text-3xl font-bold mb-4 text-gray-800 dark:text-gray-200">Prompt Gallery</h2>
<p class="text-gray-600 dark:text-gray-400 mb-8">Choose a category to browse video examples and the prompts that created them.</p>
<div id="gallery-categories" class="flex flex-wrap gap-3 mb-8"></div>
<div id="gallery-sliders-container"></div>
</section>
</div>
<style>
.video-wrapper {
position: relative;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
height: 0;
overflow: hidden;
background: #000;
border-radius: 8px;
}
.video-wrapper iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}
/* Remove the old thumbnail styles and play overlay */
.video-thumbnail, .play-overlay, .play-icon-svg {
display: none !important;
}
.gallery-examples .slider-container {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.gallery-examples .slider-container::-webkit-scrollbar {
height: 8px;
}
.gallery-examples .slider-container::-webkit-scrollbar-track {
background: transparent;
}
.gallery-examples .slider-container::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 4px;
}
.gallery-examples .slider-container::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.7);
}
</style>
@@ -0,0 +1,85 @@
<div id="generator-content">
<section id="generator">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-6 md:p-8">
<h3 class="text-xl font-bold text-gray-800 dark:text-gray-200 mb-2">Prompt Generator</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">Build a detailed prompt from scratch by selecting various cinematic elements, or generate one for an uploaded image.</p>
<div class="border-b border-gray-200 dark:border-gray-700 mb-6">
<nav class="-mb-px flex space-x-4" aria-label="Tabs">
<button id="text-to-video-tab" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm text-indigo-600 dark:text-indigo-400 border-indigo-500">Text-to-Video</button>
<button id="image-to-video-tab" class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-200 hover:border-gray-300 dark:hover:border-gray-600">Image-to-Video</button>
</nav>
</div>
<div id="text-to-video-content">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"></div>
<div class="mt-8 grid grid-cols-1 md:grid-cols-2 gap-4">
<button id="generate-text-prompt-btn" class="w-full flex items-center justify-center bg-indigo-600 text-white py-3 px-4 rounded-lg font-bold hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:bg-indigo-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>
Generate Prompt
</button>
<button id="clear-text-form-btn" class="w-full flex items-center justify-center bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200 py-3 px-4 rounded-lg font-bold hover:bg-gray-300 dark:hover:bg-gray-500">
Clear All
</button>
</div>
<div id="text-prompt-output-container" class="mt-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 hidden">
<div id="text-short-prompt-section">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold text-indigo-700 dark:text-indigo-400">Generated Prompt:</h4>
<button onclick="window.copyToClipboard('text-prompt-output')" class="p-1 text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 rounded-md" title="Copy Prompt"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg></button>
</div>
<p id="text-prompt-output" class="text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 p-3 rounded-md break-words"></p>
</div>
<div id="text-long-prompt-section" class="mt-4 hidden">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold text-green-700 dark:text-green-400">Enhanced Prompt:</h4>
<button onclick="window.copyToClipboard('text-long-prompt-output')" class="p-1 text-gray-500 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400 rounded-md" title="Copy Enhanced Prompt"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg></button>
</div>
<p id="text-long-prompt-output" class="text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 p-3 rounded-md break-words"></p>
</div>
<button id="generate-longer-text-prompt-btn" class="mt-4 py-2 px-4 bg-green-600 text-white rounded-md font-semibold hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-600 hidden">
Generate Longer Prompt
</button>
</div>
</div>
<div id="image-to-video-content" class="hidden">
<div>
<h4 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2">1. Upload Image</h4>
<input type="file" id="image-upload" accept="image/*" class="w-full text-sm text-gray-500 dark:text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 dark:file:bg-indigo-900 file:text-indigo-600 dark:file:text-indigo-300 hover:file:bg-indigo-100 dark:hover:file:bg-indigo-800"/>
<div id="image-preview-container" class="mt-4 hidden"><img id="image-preview" src="#" alt="Image Preview" class="max-h-48 rounded-lg shadow-md mx-auto"/></div>
</div>
<h4 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mt-6 mb-2">2. Add Details</h4>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" id="image-to-video-fields"></div>
<div class="mt-8 grid grid-cols-1 md:grid-cols-2 gap-4">
<button id="generate-image-prompt-btn" class="w-full flex items-center justify-center bg-indigo-600 text-white py-3 px-4 rounded-lg font-bold hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 disabled:bg-indigo-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Generate Prompt
</button>
<button id="clear-image-form-btn" class="w-full flex items-center justify-center bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200 py-3 px-4 rounded-lg font-bold hover:bg-gray-300 dark:hover:bg-gray-500">
Clear All
</button>
</div>
<div id="image-prompt-output-container" class="mt-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 hidden">
<div id="image-short-prompt-section">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold text-indigo-700 dark:text-indigo-400">Generated Prompt:</h4>
<button onclick="window.copyToClipboard('image-prompt-output')" class="p-1 text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 rounded-md" title="Copy Prompt"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg></button>
</div>
<p id="image-prompt-output" class="text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 p-3 rounded-md break-words"></p>
</div>
<div id="image-long-prompt-section" class="mt-4 hidden">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold text-green-700 dark:text-green-400">Enhanced Prompt:</h4>
<button onclick="window.copyToClipboard('image-long-prompt-output')" class="p-1 text-gray-500 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400 rounded-md" title="Copy Enhanced Prompt"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg></button>
</div>
<p id="image-long-prompt-output" class="text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 p-3 rounded-md break-words"></p>
</div>
<button id="generate-longer-image-prompt-btn" class="mt-4 py-2 px-4 bg-green-600 text-white rounded-md font-semibold hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-600 hidden">
Generate Longer Prompt
</button>
</div>
</div>
</div>
</section>
</div>
@@ -0,0 +1,42 @@
<div id="timeline-content">
<section id="timeline">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-6 md:p-8">
<h3 class="text-xl font-bold text-gray-800 dark:text-gray-200 mb-4">Timeline Prompting</h3>
<p class="text-gray-600 dark:text-gray-400 mb-6">Define what happens at specific moments in your 8-second video. The AI will enhance your descriptions into a cinematic timeline.</p>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">8-Second Timeline</label>
<div id="timeline-visualizer" class="w-full h-8 bg-gray-200 dark:bg-gray-700 rounded-lg flex overflow-hidden border border-gray-300 dark:border-gray-600"></div>
</div>
<div id="timeline-segments-container" class="space-y-4"></div>
<div class="mt-4">
<button id="add-timeline-segment-btn" class="w-full flex items-center justify-center bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200 py-2 px-4 rounded-lg font-semibold hover:bg-gray-300 dark:hover:bg-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" /></svg>
Add Segment
</button>
</div>
<div class="mt-8 border-t border-gray-200 dark:border-gray-700 pt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<button id="generate-timeline-prompt-btn" class="w-full flex items-center justify-center bg-indigo-600 text-white py-3 px-4 rounded-lg font-bold hover:bg-indigo-700">
Generate Final Prompt
</button>
<button id="clear-timeline-btn" class="w-full flex items-center justify-center bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200 py-3 px-4 rounded-lg font-bold hover:bg-gray-300 dark:hover:bg-gray-500">
Clear All
</button>
</div>
<div id="timeline-output-container" class="mt-6 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 hidden">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold text-indigo-700 dark:text-indigo-400">AI-Generated Timeline Prompt:</h4>
<button onclick="window.copyToClipboard('timeline-output')" class="p-1 text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 rounded-md" title="Copy Prompt">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
</button>
</div>
<pre><code id="timeline-output" class="text-sm text-gray-800 dark:text-gray-300 bg-white dark:bg-gray-800 p-3 rounded-md block whitespace-pre-wrap border border-gray-200 dark:border-gray-600"></code></pre>
</div>
</div>
</section>
</div>
@@ -0,0 +1,215 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { callGeminiApi } from '../api.js';
import { showToast, copyToClipboard } from '../ui.js';
export async function getTimelineContent() {
try {
const response = await fetch('/src/features/templates/timeline.html');
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return await response.text();
} catch (error) {
console.error('Failed to fetch timeline template:', error);
return '<div>Error loading content. Please check the console for details.</div>';
}
}
// --- INITIALIZATION AND LOGIC ---
export function initTimeline() {
window.copyToClipboard = copyToClipboard;
let segmentIdCounter = 0;
const addSegmentBtn = document.getElementById('add-timeline-segment-btn');
const segmentsContainer = document.getElementById('timeline-segments-container');
const generateBtn = document.getElementById('generate-timeline-prompt-btn');
const clearBtn = document.getElementById('clear-timeline-btn');
const updateTimelineVisualizer = () => {
const visualizer = document.getElementById('timeline-visualizer');
if (!visualizer) return;
visualizer.innerHTML = '';
const segments = Array.from(document.querySelectorAll('.timeline-segment'));
let totalDuration = 0;
segments.forEach(segment => {
const start = parseFloat(segment.querySelector('.segment-start').value);
const end = parseFloat(segment.querySelector('.segment-end').value);
const duration = isNaN(end) || isNaN(start) || end <= start ? 0 : end - start;
totalDuration += duration;
const color = segment.dataset.color;
const width = (duration / 8) * 100;
if (width > 0) {
const bar = document.createElement('div');
bar.className = `h-full ${color}`;
bar.style.width = `${width}%`;
bar.title = `${start.toFixed(1)}s - ${end.toFixed(1)}s`;
visualizer.appendChild(bar);
}
});
if (totalDuration < 8) {
const remainingWidth = ((8 - totalDuration) / 8) * 100;
const emptyBar = document.createElement('div');
emptyBar.className = 'h-full bg-gray-200 dark:bg-gray-700';
emptyBar.style.width = `${remainingWidth}%`;
visualizer.appendChild(emptyBar);
}
};
const addTimelineSegment = () => {
segmentIdCounter++;
const segmentDiv = document.createElement('div');
segmentDiv.className = 'timeline-segment bg-gray-50 dark:bg-gray-700 p-4 rounded-lg border border-gray-200 dark:border-gray-600';
const colors = ['bg-red-400', 'bg-blue-400', 'bg-green-400', 'bg-yellow-400', 'bg-purple-400', 'bg-pink-400', 'bg-teal-400', 'bg-orange-400'];
segmentDiv.dataset.color = colors[segmentIdCounter % colors.length];
segmentDiv.innerHTML = `
<div class="flex justify-between items-center mb-3">
<h5 class="font-semibold text-gray-800 dark:text-gray-200">Segment ${segmentIdCounter}</h5>
<button class="remove-segment-btn text-red-500 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 font-bold">&times; Remove</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
<div class="md:col-span-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start (s)</label>
<input type="number" min="0" max="8" step="0.1" class="segment-start w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100" value="0.0">
</div>
<div class="md:col-span-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">End (s)</label>
<input type="number" min="0" max="8" step="0.1" class="segment-end w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100" value="1.0">
</div>
<div class="md:col-span-3">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Prompt for this segment</label>
<textarea rows="2" class="segment-prompt w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100" placeholder="e.g., A cat appears on screen, a soft meow is heard"></textarea>
</div>
</div>
`;
segmentsContainer.appendChild(segmentDiv);
segmentDiv.querySelector('.remove-segment-btn').addEventListener('click', () => {
segmentDiv.remove();
updateTimelineVisualizer();
});
segmentDiv.querySelectorAll('input, textarea').forEach(el => {
el.addEventListener('change', updateTimelineVisualizer);
el.addEventListener('input', updateTimelineVisualizer);
});
updateTimelineVisualizer();
};
const clearAll = () => {
segmentsContainer.innerHTML = '';
segmentIdCounter = 0;
addTimelineSegment(); // Add back one default segment
document.getElementById('timeline-output-container').classList.add('hidden');
showToast('Timeline cleared!', 'info');
};
const generateTimelinePrompt = async () => {
const segments = Array.from(document.querySelectorAll('.timeline-segment'));
const segmentData = [];
let totalDuration = 0;
for (const segment of segments) {
const start = parseFloat(segment.querySelector('.segment-start').value);
const end = parseFloat(segment.querySelector('.segment-end').value);
const promptText = segment.querySelector('.segment-prompt').value;
if (isNaN(start) || isNaN(end) || end <= start || !promptText.trim()) continue;
totalDuration += (end - start);
segmentData.push({
timestamp: `${start.toFixed(2)}s-${end.toFixed(2)}s`,
prompt: promptText
});
}
if (segmentData.length === 0) {
showToast('Please add at least one valid timeline segment.', 'error');
return;
}
if (totalDuration > 8) {
showToast(`Total duration exceeds 8 seconds (${totalDuration.toFixed(1)}s). Please adjust segments.`, 'error');
return;
}
const outputContainer = document.getElementById('timeline-output-container');
const outputElement = document.getElementById('timeline-output');
const originalButtonHtml = generateBtn.innerHTML;
generateBtn.disabled = true;
generateBtn.innerHTML = `
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Generating...
`;
outputContainer.classList.remove('hidden');
outputElement.textContent = 'Creating a cinematic timeline...';
try {
const systemPrompt = `You are an expert video prompt engineer. Your task is to take a series of timeline segments with simple descriptions and enhance them into a cinematic, ready-to-use prompt.
For each segment, you will output the timestamp on one line, followed by a single, detailed paragraph on the next line. This paragraph should cinematically describe the scene. If the user's input mentions sound, incorporate it naturally into the description. If not, describe only the visuals.
Example Input:
[
{"timestamp":"0.00s-2.00s","prompt":"a cat appears, meow"},
{"timestamp":"2.00s-5.00s","prompt":"it runs away"}
]
Example Output:
timestamp: "00:00-00:02"
A sleek black cat suddenly appears in a soft pool of light, looking directly at the camera. A gentle meow is heard.
timestamp: "00:02-00:05"
The cat darts quickly out of frame, its tail disappearing into the shadows.
Now, process the following user input and generate the final timeline prompt. Output ONLY the timeline.
User Input:
${JSON.stringify(segmentData, null, 2)}`;
const result = await callGeminiApi(systemPrompt);
outputElement.textContent = result;
showToast('Timeline prompt generated successfully!', 'success');
} catch (error) {
console.error('Timeline generation error:', error);
showToast(`Generation failed: ${error.message}`, 'error');
outputElement.textContent = `Error: ${error.message}`;
} finally {
generateBtn.disabled = false;
generateBtn.innerHTML = originalButtonHtml;
}
};
// --- Attach Event Listeners ---
addSegmentBtn.addEventListener('click', addTimelineSegment);
generateBtn.addEventListener('click', generateTimelinePrompt);
clearBtn.addEventListener('click', clearAll);
// Add the first segment by default
addTimelineSegment();
}
+401
View File
@@ -0,0 +1,401 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { initGenerator, getGeneratorContent } from './features/generator.js';
import { initEnhancer, getEnhancerContent } from './features/enhancer.js';
import { initConverter, getConverterContent } from './features/converter.js';
import { initEval, getEvalContent } from './features/eval.js';
import { initAlignmentEval, getAlignmentEvalContent } from './features/alignment-eval.js';
import { initGallery, getGalleryContent } from './features/gallery.js';
import { initTimeline, getTimelineContent } from './features/timeline.js';
const mainContent = document.getElementById('main-content');
const authSectionContainer = document.getElementById('auth-section-container');
const tabs = {
generator: { getContent: getGeneratorContent, init: initGenerator, needsAuth: true },
enhancer: { getContent: getEnhancerContent, init: initEnhancer, needsAuth: true },
converter: { getContent: getConverterContent, init: initConverter, needsAuth: true },
'alignment-eval': { getContent: getAlignmentEvalContent, init: initAlignmentEval, needsAuth: true },
eval: { getContent: getEvalContent, init: initEval, needsAuth: false },
gallery: { getContent: getGalleryContent, init: initGallery, needsAuth: false },
timeline: { getContent: getTimelineContent, init: initTimeline, needsAuth: true }
};
// Dark Mode Functions
function initDarkMode() {
const themeToggleBtn = document.getElementById('theme-toggle');
const lightIcon = document.getElementById('theme-toggle-light-icon');
const darkIcon = document.getElementById('theme-toggle-dark-icon');
if (!themeToggleBtn || !lightIcon || !darkIcon) {
console.warn('Dark mode elements not found');
return;
}
// Check for saved theme preference or default to light mode
const savedTheme = localStorage.getItem('theme');
// Set initial theme - defaults to light mode
let currentTheme = savedTheme || 'light';
applyTheme(currentTheme);
// Toggle theme function
function toggleTheme() {
currentTheme = currentTheme === 'light' ? 'dark' : 'light';
applyTheme(currentTheme);
localStorage.setItem('theme', currentTheme);
// Show notification about theme change
showNotification(`Switched to ${currentTheme} mode`, 'info', 2000);
}
// Apply theme function
function applyTheme(theme) {
const html = document.documentElement;
const toggleBtn = document.getElementById('theme-toggle');
if (theme === 'dark') {
html.classList.add('dark');
// In dark mode, show sun icon (click to go to light)
lightIcon.style.display = 'block';
darkIcon.style.display = 'none';
if (toggleBtn) toggleBtn.title = 'Switch to light mode';
} else {
html.classList.remove('dark');
// In light mode, show moon icon (click to go to dark)
lightIcon.style.display = 'none';
darkIcon.style.display = 'block';
if (toggleBtn) toggleBtn.title = 'Switch to dark mode';
}
// Update CSS custom properties for smoother transitions
updateThemeProperties(theme);
}
// Update CSS custom properties
function updateThemeProperties(theme) {
const root = document.documentElement;
if (theme === 'dark') {
root.style.setProperty('--theme-bg', '#0f172a');
root.style.setProperty('--theme-text', '#f1f5f9');
root.style.setProperty('--theme-border', '#334155');
} else {
root.style.setProperty('--theme-bg', '#ffffff');
root.style.setProperty('--theme-text', '#1e293b');
root.style.setProperty('--theme-border', '#e2e8f0');
}
}
// Add event listener to toggle button
themeToggleBtn.addEventListener('click', toggleTheme);
// Add smooth transition class after initial theme is set
setTimeout(() => {
document.body.classList.add('transition-colors', 'duration-300');
// Add transitions to other elements
const elementsToTransition = document.querySelectorAll('nav, .main-tab, input, textarea, select, button');
elementsToTransition.forEach(element => {
element.classList.add('transition-colors', 'duration-300');
});
}, 100);
console.log(`Theme system initialized. Current theme: ${currentTheme} (defaults to light mode)`);
}
// This function is async to handle fetching HTML templates.
async function showMainTab(tabName) {
const feature = tabs[tabName];
if (!feature) {
console.warn(`Tab ${tabName} not found`);
return;
}
// Show/hide auth section based on feature needs
authSectionContainer.style.display = feature.needsAuth ? 'block' : 'none';
// Load tab content asynchronously.
mainContent.innerHTML = await feature.getContent();
feature.init();
// Update active tab styling
Object.keys(tabs).forEach(tabKey => {
const tabEl = document.getElementById(`${tabKey}-main-tab`);
if (tabEl) {
tabEl.classList.toggle('main-tab-active', tabKey === tabName);
}
});
// Add fade-in animation to content
mainContent.classList.add('fade-in');
setTimeout(() => {
mainContent.classList.remove('fade-in');
}, 500);
console.log(`Switched to ${tabName} tab`);
}
// Fast validation using Gemini Flash model
async function validateAccessToken() {
const accessToken = document.getElementById('access-token-input').value;
const projectId = document.getElementById('project-id-input').value;
const location = document.getElementById('location-input')?.value || 'us-central1';
const statusElement = document.getElementById('access-token-status');
const validateBtn = document.getElementById('validate-token-btn');
const authMethod = document.getElementById('auth-method-select').value;
// For API key mode, just do client-side check
if (authMethod === 'api-key') {
statusElement.textContent = '✅ Using server API key. Ready to generate!';
statusElement.className = 'text-xs mt-2 h-4 text-blue-600 dark:text-blue-400';
validateBtn.textContent = '✓ API Key Mode';
setTimeout(() => {
validateBtn.textContent = 'Validate';
}, 2000);
return;
}
// For access token mode, check fields first
if (!projectId || !accessToken) {
statusElement.textContent = 'Project ID and Token are required.';
statusElement.className = 'text-xs mt-2 h-4 text-red-600 dark:text-red-400';
return;
}
// Show loading state (should be quick with Flash)
validateBtn.disabled = true;
validateBtn.textContent = 'Validating...';
validateBtn.classList.add('loading');
statusElement.textContent = 'Checking credentials...';
statusElement.className = 'text-xs mt-2 h-4 text-gray-500 dark:text-gray-400';
try {
const response = await fetch('/api/validate-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId, accessToken, location })
});
const result = await response.json();
if (result.valid) {
statusElement.textContent = '✅ ' + result.message;
statusElement.className = 'text-xs mt-2 h-4 text-green-600 dark:text-green-400';
validateBtn.textContent = '✓ Validated';
validateBtn.classList.add('bg-green-600', 'hover:bg-green-700');
showNotification(`Token validated for ${location}!`, 'success');
setTimeout(() => {
validateBtn.textContent = 'Validate';
validateBtn.classList.remove('bg-green-600', 'hover:bg-green-700');
}, 3000);
} else {
statusElement.textContent = `${result.message}`;
statusElement.className = 'text-xs mt-2 h-4 text-red-600 dark:text-red-400';
validateBtn.textContent = 'Retry';
showNotification('Validation failed', 'error');
}
} catch (error) {
console.error('Token validation error:', error);
statusElement.textContent = 'Validation failed. Check server console.';
statusElement.className = 'text-xs mt-2 h-4 text-red-600 dark:text-red-400';
showNotification('Network error during validation', 'error');
} finally {
// Reset button state
validateBtn.disabled = false;
if (validateBtn.textContent === 'Validating...') {
validateBtn.textContent = 'Validate';
}
validateBtn.classList.remove('loading');
}
}
// Enhanced notification system with dark mode support
function showNotification(message, type = 'info', duration = 3000) {
const toast = document.getElementById('notification-toast');
if (!toast) {
console.warn('Notification toast element not found');
return;
}
// Clear any existing classes and timers
clearTimeout(toast.hideTimer);
toast.className = '';
// Set the message
toast.textContent = message;
// Base classes for the toast
const baseClasses = 'fixed bottom-20 left-1/2 transform -translate-x-1/2 px-6 py-3 rounded-full font-semibold text-sm shadow-lg transition-all duration-300 z-50';
// Type-specific styling
let typeClass = '';
switch (type) {
case 'success':
typeClass = 'bg-green-500 text-white';
break;
case 'error':
typeClass = 'bg-red-500 text-white';
break;
case 'warning':
typeClass = 'bg-yellow-500 text-white';
break;
default:
typeClass = 'bg-blue-500 text-white';
}
// Apply classes and show
toast.className = `${baseClasses} ${typeClass} opacity-100 translate-y-0`;
// Hide after duration
toast.hideTimer = setTimeout(() => {
toast.className = `${baseClasses} ${typeClass} opacity-0 translate-y-2`;
}, duration);
console.log(`Notification: ${message} (${type})`);
}
// Utility function to get current theme
function getCurrentTheme() {
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
}
// Utility function to check if user prefers reduced motion
function prefersReducedMotion() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
// Initialize keyboard shortcuts
function initKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Ctrl/Cmd + Shift + D to toggle dark mode
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'D') {
e.preventDefault();
document.getElementById('theme-toggle')?.click();
}
// Ctrl/Cmd + 1-7 for tab switching
if ((e.ctrlKey || e.metaKey) && e.key >= '1' && e.key <= '7') {
e.preventDefault();
const tabNames = Object.keys(tabs);
const tabIndex = parseInt(e.key) - 1;
if (tabNames[tabIndex]) {
showMainTab(tabNames[tabIndex]);
}
}
});
}
// Initialize animations based on user preference
function initAnimations() {
if (prefersReducedMotion()) {
document.documentElement.style.setProperty('--animation-duration', '0ms');
}
}
// Main initialization function
document.addEventListener('DOMContentLoaded', () => {
console.log('VeoStart application initializing...');
// Get DOM elements
const authMethodSelect = document.getElementById('auth-method-select');
const apiKeySection = document.getElementById('api-key-auth-section');
const accessTokenSection = document.getElementById('access-token-auth-section');
const validateTokenBtn = document.getElementById('validate-token-btn');
const authHeader = document.getElementById('auth-header');
const authContent = document.getElementById('auth-content');
const authChevron = document.getElementById('auth-chevron');
// Initialize dark mode first
initDarkMode();
// Initialize animations
initAnimations();
// Initialize keyboard shortcuts
initKeyboardShortcuts();
// Collapsible auth section logic
if (authHeader && authContent && authChevron) {
authHeader.addEventListener('click', () => {
const isHidden = authContent.classList.contains('hidden');
authContent.classList.toggle('hidden');
authChevron.classList.toggle('rotate-180');
// Add smooth animation
if (!prefersReducedMotion()) {
authChevron.style.transform = isHidden ? 'rotate(0deg)' : 'rotate(180deg)';
}
});
}
// Auth method switching
if (authMethodSelect && apiKeySection && accessTokenSection) {
authMethodSelect.addEventListener('change', () => {
const isApiKey = authMethodSelect.value === 'api-key';
apiKeySection.style.display = isApiKey ? 'block' : 'none';
accessTokenSection.style.display = isApiKey ? 'none' : 'block';
});
}
// Token validation
if (validateTokenBtn) {
validateTokenBtn.addEventListener('click', validateAccessToken);
}
// Tab navigation
Object.keys(tabs).forEach(tabKey => {
const tabEl = document.getElementById(`${tabKey}-main-tab`);
if (tabEl) {
tabEl.addEventListener('click', () => showMainTab(tabKey));
}
});
// Show default tab
showMainTab('generator');
// Welcome notification
setTimeout(() => {
const currentTheme = getCurrentTheme();
showNotification(`Welcome to V-Start! Currently in ${currentTheme} mode. Press Ctrl+Shift+D to toggle theme.`, 'info', 5000);
}, 1000);
console.log('VeoStart application initialized successfully');
});
// Handle page visibility changes
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
// Refresh theme when page becomes visible again
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
const html = document.documentElement;
if (savedTheme === 'dark' && !html.classList.contains('dark')) {
html.classList.add('dark');
} else if (savedTheme === 'light' && html.classList.contains('dark')) {
html.classList.remove('dark');
}
}
}
});
// Export functions for use in other modules
export { showNotification, getCurrentTheme, showMainTab };
+48
View File
@@ -0,0 +1,48 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export function showToast(message, type = 'success') {
const toast = document.getElementById('notification-toast');
if (!toast) return;
toast.textContent = message;
toast.className = 'show';
toast.classList.add(type);
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
export async function copyToClipboard(elementId) {
const element = document.getElementById(elementId);
if (!element) return;
const textToCopy = element.innerText;
if (!navigator.clipboard) {
showToast('Clipboard API not available.', 'error');
return;
}
try {
await navigator.clipboard.writeText(textToCopy);
showToast('Copied to clipboard!', 'success');
} catch (err) {
console.error('Failed to copy text: ', err);
showToast('Failed to copy.', 'error');
}
}
+260
View File
@@ -0,0 +1,260 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*
=====================
V-Start Application
====================
*/
/* --- CSS Variables for Light & Dark Themes --- */
:root {
/* Light Theme */
--primary-color: #4f46e5; /* Indigo */
--secondary-color: #10b981; /* Emerald */
--background-color: #f1f5f9; /* Slate 100 */
--text-primary: #1e293b; /* Slate 800 */
--text-secondary: #64748b; /* Slate 500 */
--card-background: rgba(255, 255, 255, 0.9);
--card-border: #e2e8f0; /* Slate 200 */
--input-background: #f8fafc; /* Slate 50 */
--input-border: #cbd5e1; /* Slate 300 */
--hover-background: #f1f5f9; /* Slate 100 */
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* Dark Theme */
.dark {
--primary-color: #6366f1; /* Indigo 500 */
--secondary-color: #34d399; /* Emerald 400 */
--background-color: #0f172a; /* Slate 900 */
--text-primary: #f1f5f9; /* Slate 100 */
--text-secondary: #94a3b8; /* Slate 400 */
--card-background: rgba(30, 41, 59, 0.9); /* Slate 800 with opacity */
--card-border: #334155; /* Slate 700 */
--input-background: #1e293b; /* Slate 800 */
--input-border: #475569; /* Slate 600 */
--hover-background: #1e293b; /* Slate 800 */
}
/* --- Base & Body Styles --- */
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background-color: var(--background-color);
color: var(--text-primary);
min-height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* --- Enhanced Header --- */
#main-header {
background: linear-gradient(135deg, #1f2937 0%, #111827 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: var(--shadow-lg);
}
.dark #main-header {
background: linear-gradient(135deg, #111827 0%, #000000 100%);
}
#main-header h1 {
color: white;
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
}
/* --- Main Container --- */
main {
padding: 2rem 1rem;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
/* --- Navigation Tabs --- */
nav {
background: var(--card-background);
border: 1px solid var(--card-border);
border-radius: 16px;
padding: 1rem;
box-shadow: var(--shadow-lg);
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.main-tab {
transition: all 0.3s ease;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: flex-start;
font-weight: 600;
border-radius: 12px;
border: 1px solid transparent;
}
.main-tab:hover {
background-color: var(--hover-background);
color: var(--primary-color);
transform: translateX(4px);
border-color: var(--primary-color);
}
.main-tab-active {
background-color: var(--primary-color);
color: white !important;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
border-color: var(--primary-color);
}
.main-tab-active:hover {
color: white !important;
transform: translateX(0);
background-color: var(--primary-color);
}
/* --- Section Headers --- */
h3, h4 {
position: relative;
color: var(--text-primary);
}
h3::after {
content: '';
position: absolute;
bottom: -6px;
left: 0;
width: 40px;
height: 3px;
background: var(--primary-color);
border-radius: 2px;
}
/* --- General Overrides for Tailwind Classes --- */
.bg-white {
background: var(--card-background) !important;
backdrop-filter: blur(10px);
border: 1px solid var(--card-border);
}
.border-gray-200 { border-color: var(--card-border) !important; }
.border-gray-300 { border-color: var(--input-border) !important; }
.text-gray-600 { color: var(--text-secondary) !important; }
.text-gray-700 { color: var(--text-primary) !important; }
.text-gray-800 { color: var(--text-primary) !important; }
.bg-gray-50 { background-color: var(--input-background) !important; }
.bg-gray-100 { background-color: var(--hover-background) !important; }
/* --- Buttons --- */
button:active {
transform: scale(0.97);
}
/* --- Notification Toast --- */
#notification-toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translate(-50%, 10px);
padding: 12px 24px;
border-radius: 9999px;
color: white;
font-weight: 600;
z-index: 9999;
opacity: 0;
visibility: hidden;
transition: all 0.4s ease;
box-shadow: var(--shadow-lg);
}
#notification-toast.show {
opacity: 1;
visibility: visible;
transform: translate(-50%, 0);
}
#notification-toast.success { background-color: #10b981; }
#notification-toast.error { background-color: #ef4444; }
#notification-toast.info { background-color: #3b82f6; }
/* --- Scrollbars --- */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--input-background);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--text-secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--primary-color);
}
/* --- Accessibility --- */
*:focus-visible {
outline: 3px solid var(--primary-color);
outline-offset: 2px;
border-radius: 4px;
}
/* --- Responsive Video Container --- */
.video-container {
position: relative;
overflow: hidden;
width: 100%;
padding-top: 56.25%; /* 16:9 Aspect Ratio */
}
.video-container iframe,
.video-container video {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
border-radius: 8px;
}
/* --- Dark Mode Specific Adjustments --- */
.dark img {
opacity: 0.9;
}
.dark img:hover {
opacity: 1;
}
/* --- Mobile Responsive Improvements --- */
@media (max-width: 768px) {
.main-tab {
justify-content: center;
}
.main-tab span {
display: none;
}
.main-tab svg {
margin: 0;
}
}
@@ -0,0 +1,23 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
.env
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Project
outputs/
*.log
+141
View File
@@ -0,0 +1,141 @@
# GenMedia Live
Author: [Wafae Bakkali](https://github.com/WafaeBakkali)
**Real-time multimodal creation enabled by extending the Gemini Live API with image and video generation tools.**
GenMedia Live enables multimodal AI creation by extending the [Gemini Live API](https://cloud.google.com/vertex-ai/generative-ai/docs/live-api) capabilities to image and video generation using Gemini Pro Image and Veo through function calling.
You can interact with voice or text to:
- **Generate images** from descriptions or edit existing ones
- **Create videos** from text prompts or animate images
- **Create short movies** by combining multiple video clips
- **Ask questions** about what you show on camera or upload
- **Extract frames** from videos for further editing
Users can reference previously generated images, or use images from their camera, screen share, or upload images from the UI as reference.
## Features
- **Voice Interaction**: Real-time voice conversations with the Gemini Live API
- **Text Input**: Type messages as an alternative to voice
- **Camera**: Share your camera for visual context
- **Screen Share**: Share your screen for visual context
- **Image Upload**: Upload images from your device as reference
- **Image Generation**: Create and edit images using Gemini Pro Image
- **Video Generation**: Generate videos with Veo, including image-to-video
- **Frame Extraction**: Extract frames from videos using ffmpeg
- **Video Combining**: Merge multiple videos into one using ffmpeg
- **Session Management**: 30-minute conversation history with automatic reconnection
## Prerequisites
- Python 3.10+
- Google Cloud project with Vertex AI API enabled
- ffmpeg (for video operations)
### Installing ffmpeg
**Linux (Ubuntu/Debian):**
```bash
sudo apt update && sudo apt install ffmpeg
```
**macOS:**
```bash
brew install ffmpeg
```
**Windows:**
Download from [ffmpeg.org](https://ffmpeg.org/download.html) and add to PATH.
## Setup
1. Clone the repository:
```bash
git clone https://github.com/GoogleCloudPlatform/generative-ai.git
cd generative-ai/vision/sample-apps/genmedia-live
```
2. Create and activate virtual environment:
```bash
python -m venv venv
source venv/bin/activate # Linux/Mac
# or
venv\Scripts\activate # Windows
```
3. Install dependencies:
```bash
pip install -r requirements.txt
```
4. Run the application:
```bash
python app.py
```
5. Open http://localhost:8080 in your browser.
## Authentication
GenMedia Live requires users to authenticate with their own Google Cloud credentials.
### Steps to Authenticate:
1. **Enter your Project ID** in the sidebar
2. **Click "Open Cloud Shell"** - this opens Google Cloud Shell in a new tab
3. **Run the command** in Cloud Shell:
```bash
gcloud auth print-access-token
```
4. **Copy the token** and paste it in the "Paste Token" field
5. **Click "Validate & Connect"**
If validation succeeds, you'll see "Connected!" and can start using the app.
> **Note**: Access tokens expire after ~1 hour. You'll need to generate a new token if your session expires.
## Usage
1. Complete authentication (see above)
2. Click **Voice** to start a voice conversation, or type in the text box
3. Click **Camera** to share your camera as visual context
4. Click **Screen** to share your screen as visual context
5. Use **Upload** to add reference images from your device
6. Ask GenMedia Live to generate images or videos
## Project Structure
```
genmedia-live/
├── app.py # Flask backend
├── index.html # Main page
├── style.css # Global styles
├── requirements.txt # Python dependencies
├── outputs/ # Generated files
│ ├── images/
│ └── videos/
└── src/
├── main.js # App initialization
├── ui.js # UI utilities
└── features/
├── genmedia-chat.js # Voice chat functionality
└── templates/
└── genmedia-chat.html
```
## Deployment
### Cloud Run
```bash
gcloud run deploy genmedia-live \
--source . \
--region us-central1 \
--allow-unauthenticated
```
## License
Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0).
File diff suppressed because it is too large Load Diff
+237
View File
@@ -0,0 +1,237 @@
<!--
Copyright 2026 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GenMedia Live</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>tailwind.config = { darkMode: 'class' }</script>
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
<style>
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
.fade-in { animation: fadeIn 0.5s ease-in; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body class="bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 transition-colors duration-300">
<header class="bg-gradient-to-r from-indigo-600 to-purple-600 dark:from-indigo-800 dark:to-purple-800 text-white shadow-2xl">
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl md:text-4xl font-black tracking-tight">GenMedia Live</h1>
<p class="mt-1 text-sm text-indigo-100">Real-Time Multimodal AI Creation</p>
</div>
<button id="theme-toggle" class="p-2 rounded-lg bg-indigo-700 hover:bg-indigo-600 dark:bg-indigo-900 dark:hover:bg-indigo-800 transition-colors duration-200" title="Switch theme">
<svg id="theme-toggle-light-icon" class="w-5 h-5 text-yellow-300" fill="currentColor" viewBox="0 0 20 20" style="display: none;">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"></path>
</svg>
<svg id="theme-toggle-dark-icon" class="w-5 h-5 text-indigo-200" fill="currentColor" viewBox="0 0 20 20" style="display: block;">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>
</button>
</div>
</div>
</header>
<main class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="max-w-7xl mx-auto flex gap-6">
<aside class="w-80 flex-shrink-0 space-y-4">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="p-4 bg-gradient-to-r from-indigo-600 to-purple-600 text-white">
<h3 class="text-lg font-bold">Authentication</h3>
</div>
<div class="p-4 space-y-3">
<div>
<label for="project-id-input" class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
<span class="bg-indigo-600 text-white rounded-full w-5 h-5 inline-flex items-center justify-center text-xs mr-1 font-bold">1</span>
Google Cloud Project ID
</label>
<input type="text" id="project-id-input" class="w-full p-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" placeholder="my-project-id" autocomplete="off">
</div>
<div>
<label for="location-input" class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Region</label>
<select id="location-input" class="w-full p-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
<option value="us-central1" selected>us-central1</option>
<option value="us-east1">us-east1</option>
<option value="us-east4">us-east4</option>
<option value="us-west1">us-west1</option>
<option value="us-west4">us-west4</option>
<option value="europe-west1">europe-west1</option>
<option value="europe-west2">europe-west2</option>
<option value="europe-west3">europe-west3</option>
<option value="europe-west4">europe-west4</option>
<option value="asia-east1">asia-east1</option>
<option value="asia-northeast1">asia-northeast1</option>
<option value="asia-southeast1">asia-southeast1</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
<span class="bg-indigo-600 text-white rounded-full w-5 h-5 inline-flex items-center justify-center text-xs mr-1 font-bold">2</span>
Get Access Token
</label>
<button id="get-token-btn" class="w-full py-2.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded-md font-semibold mb-1 transition-colors">
Open Cloud Shell
</button>
<p class="text-xs text-gray-500 dark:text-gray-400 italic">
Run <code class="bg-gray-100 dark:bg-gray-700 px-1 rounded font-mono text-[10px]">gcloud auth print-access-token</code>
</p>
</div>
<div>
<label for="access-token-input" class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
<span class="bg-indigo-600 text-white rounded-full w-5 h-5 inline-flex items-center justify-center text-xs mr-1 font-bold">3</span>
Paste Token
</label>
<input type="password" id="access-token-input" class="w-full p-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" placeholder="Paste token here" autocomplete="off">
</div>
<div id="validation-status" class="text-xs p-2 rounded min-h-[2rem] bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700"></div>
<button id="validate-token-btn" class="w-full py-2.5 bg-indigo-600 text-white text-sm rounded-md font-semibold hover:bg-indigo-700 transition-colors">Validate & Connect</button>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="p-4 bg-gradient-to-r from-purple-600 to-pink-600 text-white">
<h3 class="text-lg font-bold">System Instructions</h3>
</div>
<div class="p-4">
<textarea id="system-instructions-input" rows="5" class="w-full p-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" placeholder="Add custom instructions (e.g., voice style, behavior)"></textarea>
<button id="save-instructions-btn" class="w-full mt-2 py-2 bg-purple-600 text-white text-sm rounded-md font-semibold hover:bg-purple-700">Save Instructions</button>
</div>
</div>
</aside>
<div id="main-content" class="flex-1"></div>
</div>
</main>
<footer class="bg-gradient-to-r from-indigo-600 to-purple-600 dark:from-indigo-900 dark:to-purple-900 text-indigo-100 mt-12">
<div class="container mx-auto px-6 py-4 text-center text-sm">
<p>Made with ❤️ by Wafae Bakkali | wafaeb@google.com</p>
</div>
</footer>
<div id="notification-toast"></div>
<script type="module" src="/src/main.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const validateBtn = document.getElementById('validate-token-btn');
const getTokenBtn = document.getElementById('get-token-btn');
const saveInstructionsBtn = document.getElementById('save-instructions-btn');
const statusElement = document.getElementById('validation-status');
const projectIdInput = document.getElementById('project-id-input');
getTokenBtn?.addEventListener('click', () => {
const projectId = projectIdInput.value.trim();
if (!projectId) {
statusElement.innerHTML = '<span class="text-orange-600 dark:text-orange-400 font-bold">Enter Project ID first</span>';
projectIdInput.focus();
return;
}
const cloudShellUrl = `https://console.cloud.google.com/cloudshell/editor?project=${projectId}&shellonly=true&show=terminal`;
statusElement.innerHTML = '<span class="text-green-600 dark:text-green-400 font-bold">Cloud Shell opening...</span>';
window.open(cloudShellUrl, '_blank', 'noopener,noreferrer');
});
validateBtn?.addEventListener('click', async () => {
const projectId = projectIdInput.value.trim();
const location = document.getElementById('location-input').value.trim();
const accessToken = document.getElementById('access-token-input').value.trim();
if (!projectId) {
statusElement.innerHTML = '<span class="text-red-600 dark:text-red-400 font-bold">Project ID required</span>';
projectIdInput.focus();
return;
}
if (!accessToken) {
statusElement.innerHTML = '<span class="text-red-600 dark:text-red-400 font-bold">Access token required</span>';
document.getElementById('access-token-input').focus();
return;
}
validateBtn.disabled = true;
validateBtn.textContent = 'Validating...';
statusElement.innerHTML = '<span class="text-blue-600 dark:text-blue-400 font-bold">Testing credentials...</span>';
try {
const response = await fetch('/api/validate-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId, location, accessToken })
});
const result = await response.json();
if (result.valid) {
statusElement.innerHTML = '<span class="text-green-600 dark:text-green-400 font-bold">Connected!</span>';
validateBtn.textContent = 'Connected';
validateBtn.classList.add('bg-green-600', 'hover:bg-green-700');
validateBtn.classList.remove('bg-indigo-600', 'hover:bg-indigo-700');
setTimeout(() => {
validateBtn.textContent = 'Validate & Connect';
validateBtn.classList.remove('bg-green-600', 'hover:bg-green-700');
validateBtn.classList.add('bg-indigo-600', 'hover:bg-indigo-700');
}, 5000);
} else {
statusElement.innerHTML = `<span class="text-red-600 dark:text-red-400 font-bold">${result.message || 'Invalid credentials'}</span>`;
validateBtn.textContent = 'Retry Validation';
}
} catch (error) {
console.error('Validation error:', error);
statusElement.innerHTML = '<span class="text-red-600 dark:text-red-400 font-bold">Connection failed</span>';
validateBtn.textContent = 'Retry Validation';
} finally {
validateBtn.disabled = false;
}
});
saveInstructionsBtn?.addEventListener('click', async () => {
const instructions = document.getElementById('system-instructions-input').value;
try {
const response = await fetch('/api/set-system-instructions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instructions })
});
if (response.ok) {
saveInstructionsBtn.textContent = 'Saved!';
setTimeout(() => { saveInstructionsBtn.textContent = 'Save Instructions'; }, 2000);
}
} catch (error) {
console.error('Save error:', error);
}
});
});
</script>
</body>
</html>
@@ -0,0 +1,8 @@
flask>=3.0.0
flask-cors>=4.0.0
flask-socketio>=5.3.5
python-socketio>=5.10.0
google-cloud-aiplatform>=1.71.1
google-genai>=1.0.0
google-cloud-storage>=2.14.0
pillow>=10.4.0
@@ -0,0 +1,877 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { showToast } from '../ui.js';
let socket = null;
let audioContext = null;
let audioWorklet = null;
let mediaStream = null;
let sessionId = localStorage.getItem('genmedia_session_id') || `session-${Date.now()}`;
localStorage.setItem('genmedia_session_id', sessionId);
let isRecording = false;
let isConnected = false;
let isConnecting = false;
let sessionStartPending = false;
let audioQueue = [];
let isPlayingAudio = false;
let playbackAudioContext = null;
let currentAudioSource = null;
let cameraStream = null;
let screenStream = null;
let cameraInterval = null;
let screenInterval = null;
let lastCameraFrame = null;
let isProcessingFrame = false;
let conversationHistory = [];
let lastGeneratedImage = null;
let lastGeneratedVideo = null;
let currentUserName = "User";
let uploadedImages = [];
let messagesContainer, userInput, sendButton, voiceButton, cameraButton;
let screenShareButton, cameraVideo, cameraContainer, userNameInput;
let setNameButton, currentUserNameDisplay, clearAllButton, uploadButton;
function clearSessionState() {
conversationHistory = [];
lastGeneratedImage = null;
lastGeneratedVideo = null;
lastCameraFrame = null;
uploadedImages = [];
}
function showSpinner(message = 'Generating...') {
let spinner = document.getElementById('generation-spinner');
if (!spinner) {
spinner = document.createElement('div');
spinner.id = 'generation-spinner';
spinner.innerHTML = `
<div class="spinner-box">
<div class="spinner-small"></div>
<span class="spinner-text">${message}</span>
</div>
`;
document.body.appendChild(spinner);
}
spinner.querySelector('.spinner-text').textContent = message;
spinner.style.display = 'block';
}
function hideSpinner() {
const spinner = document.getElementById('generation-spinner');
if (spinner) spinner.style.display = 'none';
}
export async function getGenMediaContent() {
try {
const response = await fetch('/src/features/templates/genmedia-chat.html');
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
return await response.text();
} catch (error) {
console.error('Template load error:', error);
return '<div class="text-red-500 p-4">Failed to load chat interface. Please refresh.</div>';
}
}
export function initGenMediaChat() {
clearSessionState();
setTimeout(() => {
messagesContainer = document.getElementById('messages-container');
userInput = document.getElementById('user-input');
sendButton = document.getElementById('send-button');
voiceButton = document.getElementById('voice-button');
uploadButton = document.getElementById('upload-button');
cameraButton = document.getElementById('camera-button');
screenShareButton = document.getElementById('screen-share-button');
cameraVideo = document.getElementById('camera-video');
cameraContainer = document.getElementById('camera-container');
userNameInput = document.getElementById('user-name-input');
setNameButton = document.getElementById('set-name-btn');
currentUserNameDisplay = document.getElementById('current-user-name');
clearAllButton = document.getElementById('clear-all-button');
if (!messagesContainer || !userInput || !sendButton) {
console.error('Required UI elements not found');
return;
}
initSocket();
loadCurrentUserName();
sendButton.addEventListener('click', sendMessage);
userInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') sendMessage(); });
voiceButton.addEventListener('click', toggleVoice);
uploadButton.addEventListener('click', () => document.getElementById('image-upload-input').click());
document.getElementById('image-upload-input').addEventListener('change', handleImageUpload);
document.getElementById('remove-upload-btn')?.addEventListener('click', removeUploadedImage);
cameraButton.addEventListener('click', toggleCamera);
screenShareButton.addEventListener('click', toggleScreenShare);
setNameButton.addEventListener('click', setUserName);
userNameInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') setUserName(); });
clearAllButton.addEventListener('click', clearAllData);
document.addEventListener('click', () => {
if (playbackAudioContext?.state === 'suspended') {
playbackAudioContext.resume();
}
}, { once: true });
const imageModal = document.createElement('div');
imageModal.className = 'image-modal';
imageModal.innerHTML = '<img src="" alt="Full size">';
document.body.appendChild(imageModal);
imageModal.addEventListener('click', () => { imageModal.style.display = 'none'; });
window.enlargeImage = (src) => {
const modal = document.querySelector('.image-modal');
if (modal) {
modal.querySelector('img').src = src;
modal.style.display = 'block';
}
};
}, 100);
}
async function loadCurrentUserName() {
try {
const response = await fetch('/api/get-user-name');
const data = await response.json();
if (data.name && currentUserNameDisplay) {
currentUserNameDisplay.textContent = data.name;
currentUserName = data.name;
}
} catch (error) {
console.error('Load user name error:', error);
}
}
async function setUserName() {
const name = userNameInput.value.trim();
if (!name) {
showToast('Please enter a name', 'warning');
return;
}
try {
const response = await fetch('/api/set-user-name', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
const data = await response.json();
if (data.success && currentUserNameDisplay) {
currentUserNameDisplay.textContent = data.name;
currentUserName = data.name;
showToast(`Hi ${data.name}!`, 'success');
userNameInput.value = '';
}
} catch (error) {
console.error('Set name error:', error);
showToast('Failed to set name', 'error');
}
}
async function handleImageUpload(event) {
const files = Array.from(event.target.files);
if (!files.length) return;
if (uploadedImages.length + files.length > 14) {
showToast(`Max 14 images. You have ${uploadedImages.length}.`, 'warning');
return;
}
for (const file of files) {
if (!file.type.startsWith('image/')) {
showToast('Please select only image files', 'error');
return;
}
}
const newImages = [];
for (const file of files) {
const reader = new FileReader();
await new Promise((resolve) => {
reader.onload = (e) => {
newImages.push(e.target.result);
resolve();
};
reader.readAsDataURL(file);
});
}
uploadedImages.push(...newImages);
updateUploadPreview();
showToast(`${uploadedImages.length} image(s) ready`, 'success');
}
function updateUploadPreview() {
const previewContainer = document.getElementById('upload-preview-container');
const previewGrid = document.getElementById('upload-preview-grid');
const uploadCount = document.getElementById('upload-count');
if (!uploadedImages.length) {
previewContainer.style.display = 'none';
return;
}
uploadCount.textContent = uploadedImages.length;
previewGrid.innerHTML = '';
uploadedImages.forEach((img, index) => {
const div = document.createElement('div');
div.className = 'relative group';
div.innerHTML = `
<img src="${img}" alt="Upload ${index + 1}" class="w-full h-24 object-cover rounded border-2 border-gray-300 dark:border-gray-600" />
<button onclick="window.removeUploadedImageAt(${index})" class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-5 h-5 text-xs font-bold opacity-0 group-hover:opacity-100">×</button>
`;
previewGrid.appendChild(div);
});
previewContainer.style.display = 'block';
}
window.clearAllUploadedImages = () => {
uploadedImages = [];
updateUploadPreview();
document.getElementById('image-upload-input').value = '';
showToast('All images cleared', 'info');
};
window.removeUploadedImageAt = (index) => {
uploadedImages.splice(index, 1);
updateUploadPreview();
document.getElementById('image-upload-input').value = '';
};
function removeUploadedImage() {
uploadedImages = [];
updateUploadPreview();
document.getElementById('image-upload-input').value = '';
showToast('Images removed', 'info');
}
async function clearAllData() {
if (!confirm('Clear all content and reset session?')) return;
try {
showSpinner('Clearing...');
if (isConnected) {
socket.emit('stop_live_session', { session_id: sessionId });
isConnected = false;
}
await fetch('/api/clear-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId })
});
conversationHistory = [];
lastGeneratedImage = null;
lastGeneratedVideo = null;
lastCameraFrame = null;
uploadedImages = [];
removeUploadedImage();
localStorage.removeItem('genmedia_session_id');
sessionId = `session-${Date.now()}`;
localStorage.setItem('genmedia_session_id', sessionId);
if (messagesContainer) {
messagesContainer.innerHTML = `
<div class="text-center text-gray-500 dark:text-gray-400 py-8">
<p class="text-lg mb-2">All cleared, ${currentUserName}!</p>
<p class="text-sm italic">Start fresh - talk or type to begin</p>
</div>
`;
}
if (isRecording) stopVoice();
if (cameraStream) stopCamera();
if (screenStream) stopScreenShare();
updateConnectionStatus('Ready', 'success');
showToast('All cleared!', 'success');
} catch (error) {
console.error('Clear error:', error);
showToast('Failed to clear data', 'error');
} finally {
hideSpinner();
}
}
function updateConnectionStatus(status, type) {
const statusEl = document.getElementById('connection-status');
if (!statusEl) return;
const dotClass = type === 'success' ? 'status-ready' :
type === 'active' ? 'status-active' :
type === 'warning' ? 'status-connecting' : 'status-error';
statusEl.innerHTML = `<span class="status-dot ${dotClass}"></span><span>${status}</span>`;
}
function initSocket() {
if (socket?.connected) return;
if (socket) socket.close();
socket = io({
transports: ['polling'],
upgrade: false,
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 3000,
reconnectionAttempts: Infinity,
timeout: 60000
});
socket.on('connect', () => {
updateConnectionStatus('Ready', 'success');
socket.emit('check_session_status', { session_id: sessionId }, (response) => {
if (response?.active) {
isConnected = true;
updateConnectionStatus('Live', 'active');
}
});
});
socket.on('disconnect', (reason) => {
if (reason === 'io server disconnect' || reason === 'io client disconnect') {
updateConnectionStatus('Disconnected', 'error');
isConnected = false;
}
isConnecting = false;
sessionStartPending = false;
});
socket.on('live_session_started', (data) => {
isConnected = true;
isConnecting = false;
sessionStartPending = false;
updateConnectionStatus('Live', 'active');
if (data.user_name) currentUserName = data.user_name;
});
socket.on('live_session_ended', () => {
isConnected = false;
updateConnectionStatus('Disconnected', 'inactive');
uploadedImages = [];
updateUploadPreview();
});
socket.on('reconnect', () => {
updateConnectionStatus('Ready', 'success');
socket.emit('check_session_status', { session_id: sessionId }, (response) => {
if (response?.active) {
isConnected = true;
updateConnectionStatus('Live', 'active');
}
});
});
socket.on('audio_response', playAudioResponse);
socket.on('voice_generation_request', async (data) => {
const { text, function_name, function_args, function_call_id } = data;
conversationHistory.push({ role: 'user', content: text });
const spinnerMsgs = {
generate_image: 'Generating image...',
generate_video: 'Generating video...',
extract_frame: 'Extracting frame...',
combine_videos: 'Combining videos...'
};
showSpinner(spinnerMsgs[function_name] || 'Processing...');
try {
const payload = {
session_id: sessionId,
function_name,
function_args: { ...function_args },
function_call_id
};
if (function_name === 'generate_image') {
const ref_images = [];
if (function_args.refers_to_camera && uploadedImages.length > 0) {
ref_images.push(...uploadedImages);
}
if (function_args.refers_to_camera && lastCameraFrame && uploadedImages.length === 0) {
ref_images.push(lastCameraFrame);
}
if (ref_images.length > 0) {
payload.function_args.reference_images = ref_images;
}
}
const response = await fetch('/api/genmedia-chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
if (result.file) {
displayGeneratedContent(result);
if (result.file.type === 'image') {
lastGeneratedImage = result.file.path || result.file.url;
} else if (result.file.type === 'video') {
lastGeneratedVideo = result.file.path || result.file.url;
}
socket.emit('generation_completed', {
session_id: sessionId,
type: result.file.type,
id: result.file.id,
function_call_id,
user_request: text
});
}
if (result.response) {
conversationHistory.push({ role: 'assistant', content: result.response });
}
if (result.error) {
showToast(result.error, 'error');
addMessage('error', result.error);
}
} catch (error) {
console.error('Generation error:', error);
showToast('Generation failed', 'error');
addMessage('error', 'Generation failed: ' + error.message);
} finally {
hideSpinner();
}
});
socket.on('session_ended_reconnect', () => {
isConnected = false;
if (cameraStream || screenStream || isRecording) {
if (!isConnecting) {
isConnecting = true;
updateConnectionStatus('Reconnecting...', 'warning');
setTimeout(() => {
requestSessionStart();
isConnecting = false;
}, 500);
}
} else {
updateConnectionStatus('Ready', 'success');
}
});
socket.on('live_session_error', (data) => {
showToast('Session error: ' + data.error, 'error');
hideSpinner();
isConnecting = false;
sessionStartPending = false;
});
}
async function waitForConnection(timeout = 15000) {
return new Promise((resolve) => {
if (isConnected) {
resolve(true);
return;
}
const startTime = Date.now();
const checkConnection = setInterval(() => {
if (isConnected) {
clearInterval(checkConnection);
resolve(true);
} else if (Date.now() - startTime > timeout) {
clearInterval(checkConnection);
resolve(false);
}
}, 100);
});
}
function requestSessionStart() {
if (sessionStartPending) return false;
sessionStartPending = true;
socket.emit('start_live_session', { session_id: sessionId });
return true;
}
async function sendMessage() {
if (!userInput) return;
const message = userInput.value.trim();
if (!message) return;
if (isConnecting) {
showToast('Please wait, connecting...', 'warning');
return;
}
addMessage('user', message);
userInput.value = '';
conversationHistory.push({ role: 'user', content: message });
if (!isConnected && !isConnecting) {
isConnecting = true;
updateConnectionStatus('Connecting...', 'warning');
showSpinner('Connecting...');
requestSessionStart();
const connected = await waitForConnection(30000);
hideSpinner();
isConnecting = false;
if (!connected) {
sessionStartPending = false;
showToast('Connection timeout. Please try again.', 'error');
return;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
if (isConnected) {
if (uploadedImages.length > 0) {
socket.emit('send_message_with_images', {
session_id: sessionId,
text: message,
images: uploadedImages
});
} else {
socket.emit('send_text_message', { session_id: sessionId, text: message });
}
} else {
showToast('Not connected. Please try again.', 'error');
}
}
async function toggleVoice() {
if (!isRecording) await startVoice();
else stopVoice();
}
async function startVoice() {
try {
if (isConnecting) return;
if (!playbackAudioContext) {
playbackAudioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 24000 });
if (playbackAudioContext.state === 'suspended') await playbackAudioContext.resume();
}
if (!isConnected && !isConnecting) {
isConnecting = true;
updateConnectionStatus('Connecting...', 'warning');
showSpinner('Connecting...');
requestSessionStart();
const connected = await waitForConnection(30000);
hideSpinner();
if (!connected) {
isConnecting = false;
sessionStartPending = false;
showToast('Connection timeout. Please try again.', 'error');
return;
}
isConnecting = false;
await new Promise(resolve => setTimeout(resolve, 500));
}
if (uploadedImages.length > 0 && isConnected && socket?.connected) {
socket.emit('send_message_with_images', {
session_id: sessionId,
text: 'I have uploaded an image. Please look at it.',
images: uploadedImages
});
await new Promise(resolve => setTimeout(resolve, 3000));
}
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true, autoGainControl: true }
});
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
const source = audioContext.createMediaStreamSource(mediaStream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);
let audioBuffer = [];
let lastSendTime = Date.now();
const SEND_INTERVAL_MS = 500;
processor.onaudioprocess = (e) => {
if (!isRecording || !isConnected) return;
const inputData = e.inputBuffer.getChannelData(0);
const int16Array = new Int16Array(inputData.length);
for (let i = 0; i < inputData.length; i++) {
const s = Math.max(-1, Math.min(1, inputData[i]));
int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
audioBuffer.push(...Array.from(int16Array));
const now = Date.now();
if (now - lastSendTime >= SEND_INTERVAL_MS && audioBuffer.length > 0) {
socket.emit('send_audio', { session_id: sessionId, audio: audioBuffer });
audioBuffer = [];
lastSendTime = now;
}
};
source.connect(processor);
processor.connect(audioContext.destination);
audioWorklet = processor;
isRecording = true;
voiceButton.classList.add('active');
voiceButton.querySelector('span').textContent = 'Stop';
showToast(`Listening, ${currentUserName}!`, 'success');
} catch (error) {
console.error('Voice error:', error);
showToast('Failed to start voice', 'error');
hideSpinner();
isConnecting = false;
}
}
function stopVoice() {
if (audioWorklet) { audioWorklet.disconnect(); audioWorklet = null; }
if (audioContext) { audioContext.close(); audioContext = null; }
if (mediaStream) { mediaStream.getTracks().forEach(track => track.stop()); mediaStream = null; }
isRecording = false;
voiceButton.classList.remove('active');
voiceButton.querySelector('span').textContent = 'Voice';
updateConnectionStatus(isConnected ? 'Session Active' : 'Ready', isConnected ? 'active' : 'success');
showToast('Mic off', 'info');
}
async function toggleCamera() {
if (!cameraStream) await startCamera();
else stopCamera();
}
async function startCamera() {
try {
if (!isConnected && !isConnecting) {
isConnecting = true;
updateConnectionStatus('Connecting...', 'warning');
requestSessionStart();
await waitForConnection(30000);
isConnecting = false;
}
cameraStream = await navigator.mediaDevices.getUserMedia({ video: { width: 768, height: 768 } });
cameraVideo.srcObject = cameraStream;
cameraContainer.style.display = 'block';
cameraButton.classList.add('active');
cameraInterval = setInterval(sendCameraFrame, 2000);
showToast('Camera on', 'success');
} catch (error) {
console.error('Camera error:', error);
showToast('Camera failed', 'error');
}
}
function stopCamera() {
if (cameraInterval) { clearInterval(cameraInterval); cameraInterval = null; }
if (cameraStream) { cameraStream.getTracks().forEach(track => track.stop()); cameraStream = null; }
cameraVideo.srcObject = null;
cameraContainer.style.display = 'none';
cameraButton.classList.remove('active');
showToast('Camera off', 'info');
}
function sendCameraFrame() {
if (!cameraVideo || !cameraStream || !isConnected || isProcessingFrame) return;
isProcessingFrame = true;
const canvas = document.createElement('canvas');
canvas.width = 768; canvas.height = 768;
const ctx = canvas.getContext('2d');
const ar = cameraVideo.videoWidth / cameraVideo.videoHeight;
let dw = 768, dh = 768, ox = 0, oy = 0;
if (ar > 1) { dh = 768/ar; oy = (768-dh)/2; } else { dw = 768*ar; ox = (768-dw)/2; }
ctx.drawImage(cameraVideo, ox, oy, dw, dh);
canvas.toBlob((blob) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result.split(',')[1];
lastCameraFrame = `data:image/jpeg;base64,${base64}`;
socket.emit('send_camera_frame', { session_id: sessionId, frame: base64 });
setTimeout(() => { isProcessingFrame = false; }, 50);
};
reader.readAsDataURL(blob);
}, 'image/jpeg', 0.6);
}
async function toggleScreenShare() {
if (!screenStream) await startScreenShare();
else stopScreenShare();
}
async function startScreenShare() {
try {
if (!isConnected && !isConnecting) {
isConnecting = true;
updateConnectionStatus('Connecting...', 'warning');
requestSessionStart();
await waitForConnection(30000);
isConnecting = false;
}
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: { width: 768, height: 768 } });
const screenVideo = document.createElement('video');
screenVideo.srcObject = screenStream;
screenVideo.autoplay = true;
screenVideo.style.display = 'none';
document.body.appendChild(screenVideo);
screenShareButton.classList.add('active');
screenInterval = setInterval(() => sendScreenFrame(screenVideo), 2000);
screenStream.getVideoTracks()[0].addEventListener('ended', stopScreenShare);
showToast('Screen sharing', 'success');
} catch (error) {
console.error('Screen error:', error);
showToast('Screen share failed', 'error');
}
}
function stopScreenShare() {
if (screenInterval) { clearInterval(screenInterval); screenInterval = null; }
if (screenStream) { screenStream.getTracks().forEach(track => track.stop()); screenStream = null; }
screenShareButton.classList.remove('active');
showToast('Screen sharing stopped', 'info');
}
function sendScreenFrame(screenVideo) {
if (!screenVideo || !screenStream || !isConnected || isProcessingFrame) return;
isProcessingFrame = true;
const canvas = document.createElement('canvas');
canvas.width = 768; canvas.height = 768;
const ctx = canvas.getContext('2d');
const ar = screenVideo.videoWidth / screenVideo.videoHeight;
let dw = 768, dh = 768, ox = 0, oy = 0;
if (ar > 1) { dh = 768/ar; oy = (768-dh)/2; } else { dw = 768*ar; ox = (768-dw)/2; }
ctx.drawImage(screenVideo, ox, oy, dw, dh);
canvas.toBlob((blob) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result.split(',')[1];
lastCameraFrame = `data:image/jpeg;base64,${base64}`;
socket.emit('send_camera_frame', { session_id: sessionId, frame: base64 });
setTimeout(() => { isProcessingFrame = false; }, 50);
};
reader.readAsDataURL(blob);
}, 'image/jpeg', 0.6);
}
function playAudioResponse(data) {
if (!data.audio) return;
audioQueue.push(data);
if (!isPlayingAudio) playNextInQueue();
}
async function playNextInQueue() {
if (audioQueue.length === 0) { isPlayingAudio = false; return; }
isPlayingAudio = true;
const data = audioQueue.shift();
try {
if (!playbackAudioContext) {
playbackAudioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 24000 });
}
if (playbackAudioContext.state === 'suspended') await playbackAudioContext.resume();
const bytes = Uint8Array.from(atob(data.audio), c => c.charCodeAt(0));
const int16 = new Int16Array(bytes.buffer);
const float32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) {
float32[i] = int16[i] / (int16[i] < 0 ? 32768 : 32767);
}
const buf = playbackAudioContext.createBuffer(1, float32.length, 24000);
buf.getChannelData(0).set(float32);
const src = playbackAudioContext.createBufferSource();
src.buffer = buf;
src.connect(playbackAudioContext.destination);
currentAudioSource = src;
src.onended = () => { currentAudioSource = null; playNextInQueue(); };
src.start(0);
} catch (error) {
console.error('Audio playback error:', error);
playNextInQueue();
}
}
function addMessage(role, content) {
if (!messagesContainer) return;
const div = document.createElement('div');
div.className = `message message-${role} mb-3`;
const bubbleClass = role === 'user' ? 'bg-indigo-100 dark:bg-indigo-900 text-indigo-900 dark:text-indigo-100' :
role === 'error' ? 'bg-red-100 dark:bg-red-900 text-red-900 dark:text-red-100' :
'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100';
div.innerHTML = `<div class="inline-block px-4 py-2 rounded-xl ${bubbleClass}">${escapeHtml(content)}</div>`;
messagesContainer.appendChild(div);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
function displayGeneratedContent(result) {
if (!messagesContainer) return;
const div = document.createElement('div');
div.className = 'message message-assistant mb-4';
const file = result.file;
const url = file.url || file.path;
if (!url) return;
if (file.type === 'image') {
div.innerHTML = `
<div class="generated-content">
<div class="content-header">
<span class="content-label">Image ${file.id}</span>
<a href="${url}" download class="download-button">Download</a>
</div>
<img src="${url}" alt="Generated" class="generated-image" loading="lazy" onclick="enlargeImage('${url}')" />
</div>`;
} else if (file.type === 'video') {
div.innerHTML = `
<div class="generated-content">
<div class="content-header">
<span class="content-label">Video ${file.id}</span>
<a href="${url}" download class="download-button">Download</a>
</div>
<video controls class="generated-video"><source src="${url}" type="video/mp4"></video>
</div>`;
}
messagesContainer.appendChild(div);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
@@ -0,0 +1,331 @@
<!--
Copyright 2026 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<style>
.control-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
border-radius: 12px;
font-weight: 600;
font-size: 14px;
transition: all 0.3s ease;
border: none;
cursor: pointer;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
color: white;
}
.control-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,0,0,0.15);
}
.control-btn svg {
width: 20px;
height: 20px;
stroke: white;
fill: none;
}
.btn-voice { background: linear-gradient(135deg, #5a4fcf 0%, #6b3fa0 100%); }
.btn-voice:hover { background: linear-gradient(135deg, #4a3fbf 0%, #5b2f90 100%); }
.btn-voice.active {
background: linear-gradient(135deg, #e63946 0%, #d62839 100%);
animation: pulse-voice 1.5s infinite;
}
.btn-camera { background: linear-gradient(135deg, #0d8a7c 0%, #28a745 100%); }
.btn-camera:hover { background: linear-gradient(135deg, #0a7a6c 0%, #209538 100%); }
.btn-camera.active { background: linear-gradient(135deg, #e63946 0%, #d62839 100%); }
.btn-screen { background: linear-gradient(135deg, #2980b9 0%, #0097a7 100%); }
.btn-screen:hover { background: linear-gradient(135deg, #1a6090 0%, #007a87 100%); }
.btn-screen.active { background: linear-gradient(135deg, #e63946 0%, #d62839 100%); }
.btn-upload { background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); }
.btn-upload:hover { background: linear-gradient(135deg, #d56e12 0%, #c34400 100%); }
.btn-clear { background: linear-gradient(135deg, #c0392b 0%, #a93226 100%); }
.btn-clear:hover { background: linear-gradient(135deg, #a02920 0%, #8a2218 100%); }
.btn-send {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 24px;
border-radius: 12px;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-send:hover {
background: linear-gradient(135deg, #5a6fd6 0%, #6a4190 100%);
transform: translateY(-2px);
}
.btn-set-name {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 10px 20px;
border-radius: 10px;
font-weight: 600;
border: none;
cursor: pointer;
}
@keyframes pulse-voice {
0%, 100% { box-shadow: 0 0 0 0 rgba(240, 147, 251, 0.7); }
50% { box-shadow: 0 0 0 15px rgba(240, 147, 251, 0); }
}
#generation-spinner {
display: none;
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
}
.spinner-box {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 12px 18px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
}
.spinner-small {
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner-text { color: white; font-size: 13px; font-weight: 600; }
@keyframes spin { to { transform: rotate(360deg); } }
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
}
.status-ready { background: #38ef7d; }
.status-connecting { background: #fee140; animation: blink 1s infinite; }
.status-active { background: #38ef7d; animation: pulse-status 1.5s infinite; }
.status-error { background: #ff4b2b; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
@keyframes pulse-status {
0%, 100% { box-shadow: 0 0 0 0 rgba(56, 239, 125, 0.7); }
50% { box-shadow: 0 0 0 8px rgba(56, 239, 125, 0); }
}
.messages-container {
min-height: 400px;
max-height: 500px;
overflow-y: auto;
background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%);
border-radius: 16px;
padding: 20px;
border: 1px solid #e2e8f0;
}
.dark .messages-container {
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
border-color: #334155;
}
.generated-content {
background: white;
border-radius: 16px;
padding: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
.dark .generated-content { background: #1e293b; }
.generated-image, .generated-video {
max-width: 400px;
border-radius: 12px;
cursor: pointer;
transition: transform 0.2s;
}
.generated-image:hover { transform: scale(1.02); }
.image-modal {
display: none;
position: fixed;
z-index: 10000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.9);
cursor: pointer;
}
.image-modal img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
max-width: 90%;
max-height: 90%;
border-radius: 8px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.content-label { font-weight: 600; color: #667eea; }
.download-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 6px 14px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
text-decoration: none;
}
.camera-container {
position: fixed;
bottom: 100px;
right: 20px;
width: 240px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
background: #000;
z-index: 1000;
}
.camera-container video { width: 100%; height: auto; object-fit: cover; }
#upload-preview-grid { max-height: 200px; overflow-y: auto; }
#upload-preview-grid img { transition: transform 0.2s; }
#upload-preview-grid img:hover { transform: scale(1.05); }
</style>
<div class="space-y-6">
<div class="text-center mb-8">
<p class="text-gray-600 dark:text-gray-300 max-w-2xl mx-auto mb-2">
Voice, camera, and text-driven AI creation
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 max-w-2xl mx-auto">
Gemini Live • Gemini 3 Pro Image • Veo
</p>
</div>
<div class="bg-gradient-to-r from-indigo-50 to-purple-50 dark:from-gray-800 dark:to-gray-700 p-4 rounded-xl shadow-md border border-indigo-200 dark:border-gray-600">
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 justify-between">
<div class="flex items-center gap-2 flex-1">
<label class="text-sm font-semibold text-gray-700 dark:text-gray-300 whitespace-nowrap">
Your Name:
</label>
<input type="text" id="user-name-input" placeholder="Enter your name..."
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500" />
<button id="set-name-btn" class="btn-set-name">Set Name</button>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-2">
<span>Current:</span>
<strong id="current-user-name" class="text-indigo-600 dark:text-indigo-400 text-lg">User</strong>
</div>
</div>
</div>
<div id="camera-container" class="camera-container" style="display: none;">
<video id="camera-video" autoplay playsinline></video>
</div>
<div id="messages-container" class="messages-container">
<div class="text-center text-gray-500 dark:text-gray-400 py-8">
<p class="text-lg mb-2">Welcome to GenMedia Live</p>
<p class="text-sm italic">Set your name above, then start talking or typing</p>
</div>
</div>
<div class="bg-white dark:bg-gray-800 p-4 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700">
<div id="upload-preview-container" style="display: none;" class="mb-4 bg-gray-100 dark:bg-gray-700 p-3 rounded-xl border border-indigo-200 dark:border-gray-600">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
<span id="upload-count">0</span> Image(s) Attached (Max 14)
</span>
<button id="remove-upload-btn" class="text-red-500 hover:text-red-700 text-xs font-bold uppercase">Remove All</button>
</div>
<div id="upload-preview-grid" class="grid grid-cols-4 gap-2 bg-gray-200 dark:bg-gray-800 rounded-lg p-2"></div>
</div>
<div class="flex flex-wrap items-center gap-3 mb-4">
<button id="voice-button" class="control-btn btn-voice" title="Start voice">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
<span>Voice</span>
</button>
<button id="camera-button" class="control-btn btn-camera" title="Enable camera">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span>Camera</span>
</button>
<button id="screen-share-button" class="control-btn btn-screen" title="Share screen">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span>Screen</span>
</button>
<button id="upload-button" class="control-btn btn-upload" title="Upload image">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>Upload</span>
</button>
<input type="file" id="image-upload-input" accept="image/*" multiple class="hidden" />
<button id="clear-all-button" class="control-btn btn-clear" title="Clear all">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span>Clear</span>
</button>
<div id="connection-status" class="ml-auto flex items-center text-sm font-semibold">
<span class="status-dot status-ready"></span>
<span>Ready</span>
</div>
</div>
<div class="flex gap-3">
<input type="text" id="user-input" placeholder="Type a message or use voice..."
class="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
<button id="send-button" class="btn-send">Send</button>
</div>
</div>
</div>
@@ -0,0 +1,118 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getGenMediaContent, initGenMediaChat } from './features/genmedia-chat.js';
import { showToast } from './ui.js';
function initDarkMode() {
const themeToggleBtn = document.getElementById('theme-toggle');
const lightIcon = document.getElementById('theme-toggle-light-icon');
const darkIcon = document.getElementById('theme-toggle-dark-icon');
if (!themeToggleBtn || !lightIcon || !darkIcon) return;
const savedTheme = localStorage.getItem('theme') || 'light';
applyTheme(savedTheme);
function applyTheme(theme) {
const html = document.documentElement;
if (theme === 'dark') {
html.classList.add('dark');
lightIcon.style.display = 'block';
darkIcon.style.display = 'none';
} else {
html.classList.remove('dark');
lightIcon.style.display = 'none';
darkIcon.style.display = 'block';
}
}
themeToggleBtn.addEventListener('click', () => {
const isDark = document.documentElement.classList.contains('dark');
const newTheme = isDark ? 'light' : 'dark';
applyTheme(newTheme);
localStorage.setItem('theme', newTheme);
showToast(`Switched to ${newTheme} mode`, 'info', 2000);
});
setTimeout(() => {
document.body.classList.add('transition-colors', 'duration-300');
}, 100);
}
async function initApp() {
try {
initDarkMode();
const mainContent = document.getElementById('main-content');
if (!mainContent) {
throw new Error('Main content container not found');
}
const chatContent = await getGenMediaContent();
mainContent.innerHTML = chatContent;
mainContent.classList.add('fade-in');
setTimeout(() => mainContent.classList.remove('fade-in'), 500);
initGenMediaChat();
setTimeout(() => {
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
showToast(`Welcome to GenMedia Live! (${theme} mode)`, 'success', 3000);
}, 500);
} catch (error) {
console.error('Init failed:', error);
showToast('Failed to load. Please refresh.', 'error');
}
}
function initKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'D') {
e.preventDefault();
document.getElementById('theme-toggle')?.click();
}
});
}
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
const html = document.documentElement;
const isDark = html.classList.contains('dark');
if (savedTheme === 'dark' && !isDark) {
html.classList.add('dark');
} else if (savedTheme === 'light' && isDark) {
html.classList.remove('dark');
}
}
}
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
initApp();
initKeyboardShortcuts();
});
} else {
initApp();
initKeyboardShortcuts();
}
window.GenMediaLive = { version: '1.0.0', name: 'GenMedia Live' };
export { initApp };
@@ -0,0 +1,57 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export function showToast(message, type = 'success', duration = 3000) {
let toast = document.getElementById('notification-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'notification-toast';
document.body.appendChild(toast);
}
clearTimeout(toast.hideTimer);
toast.textContent = message;
const baseClasses = 'fixed bottom-20 left-1/2 transform -translate-x-1/2 px-6 py-3 rounded-full font-semibold text-sm shadow-lg transition-all duration-300 z-50';
const typeClasses = {
success: 'bg-green-500 text-white',
error: 'bg-red-500 text-white',
warning: 'bg-yellow-500 text-white',
info: 'bg-blue-500 text-white'
};
toast.className = `${baseClasses} ${typeClasses[type] || typeClasses.info} opacity-100 translate-y-0`;
toast.hideTimer = setTimeout(() => {
toast.className = `${baseClasses} ${typeClasses[type] || typeClasses.info} opacity-0 translate-y-2`;
}, duration);
}
export async function copyToClipboard(elementId) {
const element = document.getElementById(elementId);
if (!element || !navigator.clipboard) {
showToast('Clipboard not available', 'error');
return;
}
try {
await navigator.clipboard.writeText(element.innerText);
showToast('Copied to clipboard!', 'success');
} catch (err) {
console.error('Copy failed:', err);
showToast('Failed to copy', 'error');
}
}
@@ -0,0 +1,98 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
.dark ::-webkit-scrollbar-track {
background: #1e293b;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.dark ::-webkit-scrollbar-thumb {
background: #475569;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
*:focus {
outline: 2px solid #6366f1;
outline-offset: 2px;
}
.dark *:focus {
outline-color: #818cf8;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.5s ease-in-out;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 1s linear infinite;
}
#notification-toast {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
pointer-events: none;
}
html.dark {
color-scheme: dark;
}
html {
color-scheme: light;
}
body {
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}