File size: 9,690 Bytes
929f41f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
"""Command-line interface for Audio Video Generator."""

import logging
import os
import sys
from pathlib import Path
from typing import List, Optional

import click
from tqdm import tqdm

from audio_video_generator import __version__
from audio_video_generator.config import (
    ANIMATION_OPTIONS,
    ANIMATION_RANDOM_POOL,
    DEFAULT_OUTPUT_NAME,
    RESOLUTION_MAP,
    TRANSITION_OPTIONS,
    TRANSITION_RANDOM_POOL,
)
from audio_video_generator.core.pipeline import (
    VideoPipeline,
    VideoPipelineConfig,
)


def setup_logging(verbose: bool = False) -> None:
    """Configure logging."""
    level = logging.DEBUG if verbose else logging.INFO
    logging.basicConfig(
        level=level,
        format="%(levelname)s: %(message)s",
        handlers=[logging.StreamHandler(sys.stdout)]
    )


def validate_file(ctx, param, value):
    """Validate file exists."""
    if value is None:
        return None
    if not os.path.exists(value):
        raise click.BadParameter(f"File not found: {value}")
    return value


@click.group()
@click.version_option(version=__version__, prog_name="avg")
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
@click.pass_context
def cli(ctx: click.Context, verbose: bool) -> None:
    """Audio Video Generator - Synchronize images to audio.
    
    Generate videos by synchronizing images to audio using Whisper
    transcription and CSV mapping files.
    
    Example:
        avg generate -a audio.mp3 -c mapping.csv -i images.zip -o output.mp4
    """
    ctx.ensure_object(dict)
    ctx.obj["verbose"] = verbose
    setup_logging(verbose)


@cli.command()
@click.option(
    "--audio", "-a",
    required=True,
    type=click.Path(exists=True, dir_okay=False),
    help="Path to audio file (mp3, wav, m4a, etc.)"
)
@click.option(
    "--csv", "-c",
    required=True,
    type=click.Path(exists=True, dir_okay=False),
    help="Path to CSV mapping file (text, image columns)"
)
@click.option(
    "--images", "-i",
    type=click.Path(exists=True),
    help="Path to images (ZIP file or directory)"
)
@click.option(
    "--output", "-o",
    default=DEFAULT_OUTPUT_NAME,
    help=f"Output video filename (default: {DEFAULT_OUTPUT_NAME})"
)
@click.option(
    "--resolution", "-r",
    type=click.Choice(["landscape", "portrait", "square"]),
    default="landscape",
    help="Output resolution preset"
)
@click.option(
    "--animation-mode",
    type=click.Choice(["single", "custom", "random"]),
    default="random",
    help="Image animation selection mode"
)
@click.option(
    "--animation",
    type=click.Choice(ANIMATION_OPTIONS),
    help="Single animation to use (with --animation-mode=single)"
)
@click.option(
    "--animations",
    multiple=True,
    type=click.Choice(ANIMATION_RANDOM_POOL),
    help="Custom animations to cycle through (with --animation-mode=custom)"
)
@click.option(
    "--transition-mode",
    type=click.Choice(["single", "custom", "random"]),
    default="random",
    help="Image transition selection mode"
)
@click.option(
    "--transition",
    type=click.Choice(TRANSITION_OPTIONS),
    help="Single transition to use (with --transition-mode=single)"
)
@click.option(
    "--transitions",
    multiple=True,
    type=click.Choice(TRANSITION_RANDOM_POOL),
    help="Custom transitions to cycle through (with --transition-mode=custom)"
)
@click.option(
    "--txt-overlay",
    type=click.Path(exists=True, dir_okay=False),
    help="Path to TXT file for text overlay (one phrase per line)"
)
@click.option(
    "--font-size",
    type=int,
    default=56,
    help="Text overlay font size"
)
@click.option(
    "--text-color",
    default="#FFFFFF",
    help="Text overlay color (hex)"
)
@click.option(
    "--text-pos-x",
    type=float,
    default=0.5,
    help="Text horizontal position (0.0 to 1.0)"
)
@click.option(
    "--text-pos-y",
    type=float,
    default=0.5,
    help="Text vertical position (0.0 to 1.0)"
)
@click.option(
    "--whisper-model",
    default="base",
    help="Whisper model size (tiny, base, small, medium, large)"
)
@click.option(
    "--work-dir",
    type=click.Path(),
    default="./avg_runs",
    help="Working directory for outputs and checkpoints"
)
@click.option(
    "--fps",
    type=int,
    default=24,
    help="Output video framerate"
)
@click.option(
    "--save-checkpoints/--no-checkpoints",
    default=True,
    help="Save checkpoint files"
)
@click.option(
    "--keep-work-dir/--clean-work-dir",
    default=False,
    help="Keep working directory after completion"
)
@click.pass_context
def generate(
    ctx: click.Context,
    audio: str,
    csv: str,
    images: Optional[str],
    output: str,
    resolution: str,
    animation_mode: str,
    animation: Optional[str],
    animations: tuple,
    transition_mode: str,
    transition: Optional[str],
    transitions: tuple,
    txt_overlay: Optional[str],
    font_size: int,
    text_color: str,
    text_pos_x: float,
    text_pos_y: float,
    whisper_model: str,
    work_dir: str,
    fps: int,
    save_checkpoints: bool,
    keep_work_dir: bool
) -> None:
    """Generate video from audio, images, and CSV mapping."""
    verbose = ctx.obj.get("verbose", False)
    
    # Validate images input
    if images is None:
        click.echo("Error: --images is required (ZIP file or directory)", err=True)
        sys.exit(1)
    
    # Determine image input mode
    if images.endswith(".zip"):
        input_mode = "ZIP"
    elif os.path.isdir(images):
        input_mode = "MANUAL"
    else:
        click.echo(f"Error: Images must be a ZIP file or directory: {images}", err=True)
        sys.exit(1)
    
    # Build text style config
    text_style = {
        "font_size": font_size,
        "text_color": text_color,
        "pos_x": text_pos_x,
        "pos_y": text_pos_y,
    }
    
    # Build pipeline config
    config = VideoPipelineConfig(
        audio_path=audio,
        csv_path=csv,
        input_mode=input_mode,
        zip_path=images if input_mode == "ZIP" else None,
        manual_images_dir=images if input_mode == "MANUAL" else None,
        output_filename=output,
        resolution=resolution,
        animation_mode=animation_mode,
        single_animation=animation,
        custom_animations=list(animations) if animations else None,
        transition_mode=transition_mode,
        single_transition=transition,
        custom_transitions=list(transitions) if transitions else None,
        txt_path=txt_overlay,
        enable_text_overlay=txt_overlay is not None,
        text_style=text_style if txt_overlay else None,
        whisper_model=whisper_model,
        work_root=work_dir,
        fps=fps,
        save_checkpoints=save_checkpoints,
        keep_work_dir=keep_work_dir,
    )
    
    # Run pipeline
    pipeline = VideoPipeline(config)
    
    try:
        click.echo(f"Starting video generation...")
        click.echo(f"  Audio: {audio}")
        click.echo(f"  CSV: {csv}")
        click.echo(f"  Images: {images} ({input_mode} mode)")
        click.echo(f"  Output: {output}")
        click.echo(f"  Resolution: {resolution} ({RESOLUTION_MAP[resolution][0]}x{RESOLUTION_MAP[resolution][1]})")
        
        result = pipeline.run(progress_callback=lambda msg, pct: click.echo(f"  [{pct*100:5.1f}%] {msg}"))
        
        click.echo(f"\nSuccess! Video saved to: {result['output_path']}")
        
        if result.get("drive_path"):
            click.echo(f"Also saved to Drive: {result['drive_path']}")
        
        if verbose and result.get("report"):
            click.echo("\n--- Processing Report ---")
            click.echo(result["report"])
        
    except Exception as e:
        click.echo(f"\nError: {e}", err=True)
        if verbose:
            import traceback
            click.echo(traceback.format_exc(), err=True)
        sys.exit(1)


@cli.command()
@click.option(
    "--port", "-p",
    type=int,
    default=7860,
    help="Port to run web UI on"
)
@click.option(
    "--host", "-h",
    default="127.0.0.1",
    help="Host to bind to"
)
@click.option(
    "--share/--no-share",
    default=False,
    help="Create public shareable link"
)
def web(port: int, host: str, share: bool) -> None:
    """Launch Gradio web interface."""
    try:
        from audio_video_generator.web.gradio_ui import launch_ui
        click.echo(f"Starting web UI on http://{host}:{port}")
        launch_ui(host=host, port=port, share=share)
    except ImportError as e:
        click.echo(f"Error: Could not load web UI - {e}", err=True)
        sys.exit(1)


@cli.command()
def models() -> None:
    """List available Whisper models."""
    models_info = [
        ("tiny", "39 MB", "Fastest, lowest accuracy"),
        ("base", "74 MB", "Good balance (default)"),
        ("small", "244 MB", "Better accuracy"),
        ("medium", "769 MB", "High accuracy"),
        ("large", "1550 MB", "Best accuracy, slowest"),
    ]
    
    click.echo("Available Whisper models:")
    click.echo()
    
    for name, size, desc in models_info:
        marker = "  -> " if name == "base" else "     "
        click.echo(f"{marker}{name:8}  {size:10}  {desc}")
    
    click.echo()
    click.echo("Use with: avg generate --whisper-model <model>")


@cli.command()
def animations() -> None:
    """List available animations and transitions."""
    click.echo("Image Animations:")
    for anim in ANIMATION_OPTIONS:
        click.echo(f"  - {anim}")
    
    click.echo()
    click.echo("Image Transitions:")
    for trans in TRANSITION_OPTIONS:
        click.echo(f"  - {trans}")


def main() -> None:
    """Entry point for the CLI."""
    cli()


if __name__ == "__main__":
    main()