Files changed (1) hide show
  1. app.py +315 -0
app.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useCallback } from 'react';
2
+ import type { FormState, AppStatus } from './types';
3
+ import { createCompositeImage } from './utils/fileUtils';
4
+ import { generateVideo } from './services/geminiService';
5
+
6
+ const FilmIcon: React.FC<{ className?: string }> = ({ className }) => (
7
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}>
8
+ <path d="M4.5 4.5a3 3 0 0 0-3 3v9a3 3 0 0 0 3 3h15a3 3 0 0 0 3-3v-9a3 3 0 0 0-3-3h-15Zm-1.5 3a1.5 1.5 0 0 1 1.5-1.5h15a1.5 1.5 0 0 1 1.5 1.5v2.25h-18v-2.25Zm18 4.5v-1.5h-18v1.5h18Zm-18 1.5h18v2.25a1.5 1.5 0 0 1-1.5 1.5h-15a1.5 1.5 0 0 1-1.5-1.5v-2.25Z" />
9
+ </svg>
10
+ );
11
+
12
+ const UploadIcon: React.FC<{ className?: string }> = ({ className }) => (
13
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}>
14
+ <path fillRule="evenodd" d="M11.47 2.47a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06l-3.22-3.22V16.5a.75.75 0 0 1-1.5 0V4.81L8.03 8.03a.75.75 0 0 1-1.06-1.06l4.5-4.5ZM3 15.75a.75.75 0 0 1 .75.75v2.25a1.5 1.5 0 0 0 1.5 1.5h13.5a1.5 1.5 0 0 0 1.5-1.5V16.5a.75.75 0 0 1 1.5 0v2.25a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3V16.5a.75.75 0 0 1 .75-.75Z" clipRule="evenodd" />
15
+ </svg>
16
+ );
17
+
18
+ const Header: React.FC = () => (
19
+ <header className="w-full bg-slate-900/50 backdrop-blur-sm p-4 border-b border-slate-700 fixed top-0 left-0 z-10">
20
+ <div className="container mx-auto flex items-center gap-3">
21
+ <FilmIcon className="w-8 h-8 text-sky-400" />
22
+ <h1 className="text-2xl font-bold tracking-tight text-white">Dialogue Video Weaver</h1>
23
+ </div>
24
+ </header>
25
+ );
26
+
27
+ interface ImageUploaderProps {
28
+ onFileChange: (file: File | null) => void;
29
+ label: string;
30
+ id: string;
31
+ uploadText: string;
32
+ }
33
+
34
+ const ImageUploader: React.FC<ImageUploaderProps> = ({ onFileChange, label, id, uploadText }) => {
35
+ const [preview, setPreview] = useState<string | null>(null);
36
+ const [fileName, setFileName] = useState<string>('');
37
+ const fileInputRef = useRef<HTMLInputElement>(null);
38
+
39
+ const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
40
+ const file = event.target.files?.[0] || null;
41
+ onFileChange(file);
42
+ if (file) {
43
+ setFileName(file.name);
44
+ const reader = new FileReader();
45
+ reader.onloadend = () => {
46
+ setPreview(reader.result as string);
47
+ };
48
+ reader.readAsDataURL(file);
49
+ } else {
50
+ setPreview(null);
51
+ setFileName('');
52
+ }
53
+ };
54
+
55
+ const handleButtonClick = () => {
56
+ fileInputRef.current?.click();
57
+ };
58
+
59
+ return (
60
+ <div>
61
+ <label htmlFor={id} className="block text-sm font-medium text-slate-300 mb-2">{label}</label>
62
+ <div
63
+ className="mt-1 flex justify-center items-center px-6 pt-5 pb-6 border-2 border-slate-600 border-dashed rounded-lg h-48 bg-slate-800/50 cursor-pointer hover:border-sky-500 transition-colors"
64
+ onClick={handleButtonClick}
65
+ >
66
+ <input ref={fileInputRef} id={id} name={id} type="file" className="sr-only" onChange={handleFileChange} accept="image/*" />
67
+ {preview ? (
68
+ <img src={preview} alt="Preview" className="max-h-full max-w-full object-contain rounded-md" />
69
+ ) : (
70
+ <div className="text-center">
71
+ <UploadIcon className="mx-auto h-12 w-12 text-slate-500" />
72
+ <p className="mt-2 text-sm text-slate-400">
73
+ <span className="font-semibold text-sky-400">Click to upload</span> {uploadText}
74
+ </p>
75
+ <p className="text-xs text-slate-500">{fileName || 'PNG, JPG, GIF up to 10MB'}</p>
76
+ </div>
77
+ )}
78
+ </div>
79
+ </div>
80
+ );
81
+ };
82
+
83
+ interface LabeledInputProps {
84
+ id: string;
85
+ label: string;
86
+ value: string;
87
+ onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
88
+ placeholder: string;
89
+ isTextarea?: boolean;
90
+ type?: string;
91
+ helpText?: string;
92
+ }
93
+
94
+ const LabeledInput: React.FC<LabeledInputProps> = ({ id, label, value, onChange, placeholder, isTextarea = false, type = 'text', helpText }) => {
95
+ const commonProps = {
96
+ id,
97
+ name: id,
98
+ value,
99
+ onChange,
100
+ placeholder,
101
+ className: "block w-full bg-slate-800 border-slate-600 rounded-md shadow-sm focus:ring-sky-500 focus:border-sky-500 sm:text-sm text-white px-3 py-2 transition",
102
+ };
103
+ return (
104
+ <div>
105
+ <label htmlFor={id} className="block text-sm font-medium text-slate-300 mb-2">{label}</label>
106
+ {isTextarea ? (
107
+ <textarea {...commonProps} rows={6} />
108
+ ) : (
109
+ <input type={type} {...commonProps} />
110
+ )}
111
+ {helpText && <p className="mt-2 text-xs text-slate-400">{helpText}</p>}
112
+ </div>
113
+ );
114
+ };
115
+
116
+ const Loader: React.FC<{ message: string }> = ({ message }) => (
117
+ <div className="flex flex-col items-center justify-center h-full bg-slate-800/50 rounded-lg p-8">
118
+ <svg className="animate-spin h-12 w-12 text-sky-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
119
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
120
+ <path className="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>
121
+ </svg>
122
+ <p className="mt-4 text-lg font-semibold text-white animate-pulse">{message}</p>
123
+ <p className="mt-2 text-sm text-slate-400">AI video generation can take several minutes.</p>
124
+ </div>
125
+ );
126
+
127
+ const VideoResult: React.FC<{ videoUrl: string }> = ({ videoUrl }) => (
128
+ <div className="flex flex-col items-center justify-center h-full bg-slate-800/50 rounded-lg p-4">
129
+ <h2 className="text-xl font-bold mb-4">Your Video is Ready!</h2>
130
+ <video controls src={videoUrl} className="w-full max-w-full rounded-lg shadow-lg">
131
+ Your browser does not support the video tag.
132
+ </video>
133
+ <a
134
+ href={videoUrl}
135
+ download="dialogue-video.mp4"
136
+ className="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-sky-600 hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500 focus:ring-offset-slate-900"
137
+ >
138
+ Download Video
139
+ </a>
140
+ </div>
141
+ );
142
+
143
+
144
+ const App: React.FC = () => {
145
+ const [formState, setFormState] = useState<FormState>({
146
+ backgroundFile: null,
147
+ hostDescription: '',
148
+ expertDescription: '',
149
+ script: '',
150
+ elevenLabsApiKey: '',
151
+ hostPhoto: null,
152
+ hostElevenLabsVoiceId: '',
153
+ expertPhoto: null,
154
+ expertElevenLabsVoiceId: '',
155
+ });
156
+
157
+ const [appStatus, setAppStatus] = useState<AppStatus>({
158
+ isLoading: false,
159
+ message: '',
160
+ error: null,
161
+ videoUrl: null,
162
+ });
163
+
164
+ const isFormValid =
165
+ formState.backgroundFile &&
166
+ formState.hostDescription &&
167
+ formState.expertDescription &&
168
+ formState.script &&
169
+ formState.elevenLabsApiKey &&
170
+ formState.hostPhoto &&
171
+ formState.hostElevenLabsVoiceId &&
172
+ formState.expertPhoto &&
173
+ formState.expertElevenLabsVoiceId;
174
+
175
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
176
+ const { name, value } = e.target;
177
+ setFormState(prev => ({ ...prev, [name]: value }));
178
+ };
179
+
180
+ const handleFileChange = (file: File | null) => {
181
+ setFormState(prev => ({ ...prev, backgroundFile: file }));
182
+ };
183
+
184
+ const handleHostPhotoChange = (file: File | null) => {
185
+ setFormState(prev => ({ ...prev, hostPhoto: file }));
186
+ };
187
+
188
+ const handleExpertPhotoChange = (file: File | null) => {
189
+ setFormState(prev => ({ ...prev, expertPhoto: file }));
190
+ };
191
+
192
+ const onProgress = useCallback((message: string) => {
193
+ setAppStatus(prev => ({...prev, message}));
194
+ }, []);
195
+
196
+ const handleGenerateVideo = async () => {
197
+ if (!isFormValid) return;
198
+
199
+ setAppStatus({ isLoading: true, message: 'Creating composite image...', error: null, videoUrl: null });
200
+
201
+ try {
202
+ const compositeImage = await createCompositeImage(
203
+ formState.backgroundFile!,
204
+ formState.hostPhoto!,
205
+ formState.expertPhoto!
206
+ );
207
+
208
+ const prompt = `
209
+ Create a short video based on the following scene depicted in the provided image.
210
+
211
+ **Scene:**
212
+ The provided image shows a background with two characters: a 'host' on the left and an 'expert' on the right.
213
+
214
+ **Characters:**
215
+ 1. The 'host' (left) is described as: "${formState.hostDescription}". They should speak with a voice inspired by ElevenLabs voice ID "${formState.hostElevenLabsVoiceId}".
216
+ 2. The 'expert' (right) is described as: "${formState.expertDescription}". They should speak with a voice inspired by ElevenLabs voice ID "${formState.expertElevenLabsVoiceId}".
217
+
218
+ **Action & Dialogue:**
219
+ Animate the two characters in the image talking based on the script below. Their expressions and gestures should match the tone of the dialogue. The camera should focus on the character who is speaking.
220
+
221
+ **Script:**
222
+ ---
223
+ ${formState.script}
224
+ ---
225
+
226
+ **Style:** The video should be well-lit, with a professional, interview-like quality. The animation should be smooth and natural.
227
+ `;
228
+
229
+ const videoUrl = await generateVideo(
230
+ prompt,
231
+ { imageBytes: compositeImage.imageBytes, mimeType: compositeImage.mimeType },
232
+ onProgress
233
+ );
234
+
235
+ setAppStatus({ isLoading: false, message: 'Done!', error: null, videoUrl });
236
+ } catch (error) {
237
+ console.error(error);
238
+ const errorMessage = error instanceof Error ? error.message : "An unknown error occurred.";
239
+ setAppStatus({ isLoading: false, message: '', error: errorMessage, videoUrl: null });
240
+ }
241
+ };
242
+
243
+ return (
244
+ <>
245
+ <Header />
246
+ <main className="container mx-auto p-4 pt-24 min-h-screen">
247
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
248
+ {/* Left Column: Form */}
249
+ <div className="bg-slate-800/50 p-6 rounded-lg border border-slate-700 space-y-6">
250
+ <div>
251
+ <h2 className="text-xl font-bold text-white">API Configuration</h2>
252
+ <p className="text-sm text-slate-400 mb-4">Your keys are used to inform AI voice style.</p>
253
+ <LabeledInput id="elevenLabsApiKey" label="ElevenLabs API Key" value={formState.elevenLabsApiKey} onChange={handleInputChange} placeholder="Enter your ElevenLabs API Key" type="password" />
254
+ </div>
255
+
256
+ <div className="border-t border-slate-700 pt-6">
257
+ <h2 className="text-xl font-bold text-white">1. Set the Scene</h2>
258
+ <ImageUploader id="backgroundFile" label="Background Image" onFileChange={handleFileChange} uploadText="a background image" />
259
+ </div>
260
+
261
+ <div className="border-t border-slate-700 pt-6">
262
+ <h2 className="text-xl font-bold text-white">2. Define Characters</h2>
263
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
264
+ {/* Host Column */}
265
+ <div className="space-y-4">
266
+ <h3 className="text-lg font-semibold text-sky-400">Host</h3>
267
+ <ImageUploader id="hostPhoto" label="Host Photo" onFileChange={handleHostPhotoChange} uploadText="the host's photo" />
268
+ <LabeledInput id="hostDescription" label="Host Description" value={formState.hostDescription} onChange={handleInputChange} placeholder="e.g., A charismatic host in a sharp suit." />
269
+ <LabeledInput id="hostElevenLabsVoiceId" label="ElevenLabs Voice ID" value={formState.hostElevenLabsVoiceId} onChange={handleInputChange} placeholder="e.g., '21m00Tcm4TlvDq8ikWAM'" helpText="Voice ID for the host's dialogue." />
270
+ </div>
271
+ {/* Expert Column */}
272
+ <div className="space-y-4">
273
+ <h3 className="text-lg font-semibold text-teal-400">Expert</h3>
274
+ <ImageUploader id="expertPhoto" label="Expert Photo" onFileChange={handleExpertPhotoChange} uploadText="the expert's photo" />
275
+ <LabeledInput id="expertDescription" label="Expert Description" value={formState.expertDescription} onChange={handleInputChange} placeholder="e.g., A scientist with glasses." />
276
+ <LabeledInput id="expertElevenLabsVoiceId" label="ElevenLabs Voice ID" value={formState.expertElevenLabsVoiceId} onChange={handleInputChange} placeholder="e.g., '29vD33N1CtxCmqQRPOHJ'" helpText="Voice ID for the expert's dialogue." />
277
+ </div>
278
+ </div>
279
+ </div>
280
+
281
+ <div className="border-t border-slate-700 pt-6">
282
+ <h2 className="text-xl font-bold text-white">3. Write the Script</h2>
283
+ <LabeledInput id="script" label="Dialogue Script" value={formState.script} onChange={handleInputChange} placeholder="Host: Welcome to the show!&#10;Expert: Glad to be here." isTextarea />
284
+ </div>
285
+
286
+ <button
287
+ onClick={handleGenerateVideo}
288
+ disabled={!isFormValid || appStatus.isLoading}
289
+ className="w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-sky-600 hover:bg-sky-700 disabled:bg-slate-600 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500 focus:ring-offset-slate-900 transition-colors"
290
+ >
291
+ <FilmIcon className="w-5 h-5" />
292
+ {appStatus.isLoading ? 'Generating...' : 'Generate Video'}
293
+ </button>
294
+ </div>
295
+
296
+ {/* Right Column: Output */}
297
+ <div className="bg-slate-800/20 p-6 rounded-lg border border-slate-700 min-h-[500px] lg:min-h-0 flex flex-col justify-center">
298
+ {appStatus.isLoading && <Loader message={appStatus.message} />}
299
+ {appStatus.error && <div className="text-center text-red-400"><p className="font-bold">Generation Failed</p><p>{appStatus.error}</p></div>}
300
+ {appStatus.videoUrl && <VideoResult videoUrl={appStatus.videoUrl} />}
301
+ {!appStatus.isLoading && !appStatus.error && !appStatus.videoUrl && (
302
+ <div className="text-center text-slate-500">
303
+ <FilmIcon className="mx-auto h-24 w-24" />
304
+ <p className="mt-4 text-lg">Your generated video will appear here.</p>
305
+ <p>Fill out the form to get started.</p>
306
+ </div>
307
+ )}
308
+ </div>
309
+ </div>
310
+ </main>
311
+ </>
312
+ );
313
+ };
314
+
315
+ export default App;