karaoke-gen 0.96.0__py3-none-any.whl → 0.101.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. backend/api/routes/admin.py +696 -92
  2. backend/api/routes/audio_search.py +29 -8
  3. backend/api/routes/file_upload.py +99 -22
  4. backend/api/routes/health.py +65 -0
  5. backend/api/routes/internal.py +6 -0
  6. backend/api/routes/jobs.py +28 -1
  7. backend/api/routes/review.py +13 -6
  8. backend/api/routes/tenant.py +120 -0
  9. backend/api/routes/users.py +472 -51
  10. backend/main.py +31 -2
  11. backend/middleware/__init__.py +7 -1
  12. backend/middleware/tenant.py +192 -0
  13. backend/models/job.py +19 -3
  14. backend/models/tenant.py +208 -0
  15. backend/models/user.py +18 -0
  16. backend/services/email_service.py +253 -6
  17. backend/services/encoding_service.py +128 -31
  18. backend/services/firestore_service.py +6 -0
  19. backend/services/job_manager.py +44 -2
  20. backend/services/langfuse_preloader.py +98 -0
  21. backend/services/nltk_preloader.py +122 -0
  22. backend/services/spacy_preloader.py +65 -0
  23. backend/services/stripe_service.py +133 -11
  24. backend/services/tenant_service.py +285 -0
  25. backend/services/user_service.py +85 -7
  26. backend/tests/emulator/conftest.py +22 -1
  27. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  28. backend/tests/test_admin_job_files.py +337 -0
  29. backend/tests/test_admin_job_reset.py +384 -0
  30. backend/tests/test_admin_job_update.py +326 -0
  31. backend/tests/test_email_service.py +233 -0
  32. backend/tests/test_impersonation.py +223 -0
  33. backend/tests/test_job_creation_regression.py +4 -0
  34. backend/tests/test_job_manager.py +171 -9
  35. backend/tests/test_jobs_api.py +11 -1
  36. backend/tests/test_made_for_you.py +2086 -0
  37. backend/tests/test_models.py +139 -0
  38. backend/tests/test_spacy_preloader.py +119 -0
  39. backend/tests/test_tenant_api.py +350 -0
  40. backend/tests/test_tenant_middleware.py +345 -0
  41. backend/tests/test_tenant_models.py +406 -0
  42. backend/tests/test_tenant_service.py +418 -0
  43. backend/utils/test_data.py +27 -0
  44. backend/workers/screens_worker.py +16 -6
  45. backend/workers/video_worker.py +8 -3
  46. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
  47. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +58 -39
  48. lyrics_transcriber/correction/agentic/agent.py +17 -6
  49. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
  50. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  51. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  52. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  53. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  54. lyrics_transcriber/frontend/src/api.ts +13 -5
  55. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  56. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
  57. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
  58. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/licenses/LICENSE +0 -0
@@ -32,19 +32,24 @@ class AnchorSequenceFinder:
32
32
  progress_check_interval: int = 50, # Check progress every N iterations
33
33
  logger: Optional[logging.Logger] = None,
34
34
  ):
35
+ init_start = time.time()
35
36
  self.min_sequence_length = min_sequence_length
36
37
  self.min_sources = min_sources
37
38
  self.timeout_seconds = timeout_seconds
38
39
  self.max_iterations_per_ngram = max_iterations_per_ngram
39
40
  self.progress_check_interval = progress_check_interval
40
41
  self.logger = logger or logging.getLogger(__name__)
42
+
43
+ self.logger.info("Initializing AnchorSequenceFinder...")
41
44
  self.phrase_analyzer = PhraseAnalyzer(logger=self.logger)
42
45
  self.used_positions = {}
43
46
 
44
47
  # Initialize cache directory
45
48
  self.cache_dir = Path(cache_dir)
46
49
  self.cache_dir.mkdir(parents=True, exist_ok=True)
47
- self.logger.info(f"Initialized AnchorSequenceFinder with cache dir: {self.cache_dir}, timeout: {timeout_seconds}s")
50
+
51
+ init_elapsed = time.time() - init_start
52
+ self.logger.info(f"Initialized AnchorSequenceFinder in {init_elapsed:.2f}s (cache: {self.cache_dir}, timeout: {timeout_seconds}s)")
48
53
 
49
54
  def _check_timeout(self, start_time: float, operation_name: str = "operation"):
50
55
  """Check if timeout has occurred and raise exception if so."""
@@ -245,6 +250,65 @@ class AnchorSequenceFinder:
245
250
  self.logger.error(f"Unexpected error loading cache: {type(e).__name__}: {e}")
246
251
  return None
247
252
 
253
+ def _process_ngram_length_no_state(
254
+ self,
255
+ n: int,
256
+ trans_words: List[str],
257
+ all_words: List[Word],
258
+ ref_texts_clean: Dict[str, List[str]],
259
+ ref_words: Dict[str, List[Word]],
260
+ min_sources: int,
261
+ ) -> List[AnchorSequence]:
262
+ """Process a single n-gram length without modifying shared state (thread-safe).
263
+
264
+ This version doesn't track used positions - overlap filtering happens later
265
+ in _remove_overlapping_sequences. This allows parallel processing of different
266
+ n-gram lengths.
267
+ """
268
+ candidate_anchors = []
269
+
270
+ # Build hash-based index for O(1) lookups
271
+ ngram_index = self._build_ngram_index(ref_texts_clean, n)
272
+
273
+ # Generate n-grams from transcribed text
274
+ trans_ngrams = self._find_ngrams(trans_words, n)
275
+
276
+ # Single pass through all transcription n-grams
277
+ for ngram, trans_pos in trans_ngrams:
278
+ # Use indexed lookup (O(1) instead of O(n))
279
+ ngram_tuple = tuple(ngram)
280
+ if ngram_tuple not in ngram_index:
281
+ continue
282
+
283
+ # Find matches in all sources (no used_positions check - handled later)
284
+ matches = {}
285
+ source_positions = ngram_index[ngram_tuple]
286
+ for source, positions in source_positions.items():
287
+ if positions:
288
+ matches[source] = positions[0] # Take first position
289
+
290
+ if len(matches) >= min_sources:
291
+ # Get Word IDs for transcribed words
292
+ transcribed_word_ids = [w.id for w in all_words[trans_pos : trans_pos + n]]
293
+
294
+ # Get Word IDs for reference words
295
+ reference_word_ids = {
296
+ source: [w.id for w in ref_words[source][pos : pos + n]]
297
+ for source, pos in matches.items()
298
+ }
299
+
300
+ anchor = AnchorSequence(
301
+ id=WordUtils.generate_id(),
302
+ transcribed_word_ids=transcribed_word_ids,
303
+ transcription_position=trans_pos,
304
+ reference_positions=matches,
305
+ reference_word_ids=reference_word_ids,
306
+ confidence=len(matches) / len(ref_texts_clean),
307
+ )
308
+ candidate_anchors.append(anchor)
309
+
310
+ return candidate_anchors
311
+
248
312
  def _process_ngram_length(
249
313
  self,
250
314
  n: int,
@@ -408,45 +472,95 @@ class AnchorSequenceFinder:
408
472
  min_sources=self.min_sources,
409
473
  )
410
474
 
411
- # Process n-gram lengths sequentially (single-threaded for cloud compatibility)
475
+ # Process n-gram lengths in parallel for better performance
476
+ # The overlap filtering at the end handles deduplication, so we don't
477
+ # need to track used_positions during processing
412
478
  candidate_anchors = []
413
-
479
+
414
480
  # Check timeout before processing
415
481
  self._check_timeout(start_time, "n-gram processing start")
416
- self.logger.info(f"🔍 ANCHOR SEARCH: Starting sequential n-gram processing ({len(n_gram_lengths)} lengths)")
417
-
418
- batch_size = 10
419
- batch_results = []
420
-
421
- for i, n in enumerate(n_gram_lengths):
422
- try:
423
- # Check timeout periodically
424
- if self.timeout_seconds > 0:
425
- elapsed_time = time.time() - start_time
426
- if elapsed_time > self.timeout_seconds:
427
- self.logger.warning(f"🔍 ANCHOR SEARCH: ⏰ Timeout reached at n-gram {n}, stopping")
428
- break
429
-
430
- anchors = self._process_ngram_length(
431
- n, trans_words, all_words, ref_texts_clean, ref_words, self.min_sources
432
- )
433
- candidate_anchors.extend(anchors)
434
-
435
- # Batch logging
436
- batch_results.append((n, len(anchors)))
437
-
438
- # Log progress every batch_size results or on the last result
439
- if (i + 1) % batch_size == 0 or (i + 1) == len(n_gram_lengths):
440
- total_anchors_in_batch = sum(anchor_count for _, anchor_count in batch_results)
441
- n_gram_ranges = [str(ng) for ng, _ in batch_results]
442
- range_str = f"{n_gram_ranges[0]}-{n_gram_ranges[-1]}" if len(n_gram_ranges) > 1 else n_gram_ranges[0]
443
- self.logger.debug(f"🔍 ANCHOR SEARCH: Completed n-gram lengths {range_str} - found {total_anchors_in_batch} anchors")
444
- batch_results = []
445
-
446
- except Exception as e:
447
- self.logger.warning(f"🔍 ANCHOR SEARCH: ⚠️ n-gram length {n} failed: {str(e)}")
448
- batch_results.append((n, 0))
449
- continue
482
+
483
+ # Determine parallelization strategy
484
+ import os
485
+ from concurrent.futures import ThreadPoolExecutor, as_completed
486
+
487
+ # Use parallel processing by default, can be disabled via env var
488
+ use_parallel = os.getenv("ANCHOR_SEARCH_SEQUENTIAL", "0").lower() not in {"1", "true", "yes"}
489
+ max_workers = int(os.getenv("ANCHOR_SEARCH_WORKERS", "4"))
490
+
491
+ if use_parallel and len(n_gram_lengths) > 1:
492
+ self.logger.info(f"🔍 ANCHOR SEARCH: Starting PARALLEL n-gram processing ({len(n_gram_lengths)} lengths, {max_workers} workers)")
493
+
494
+ # Process in parallel - each n-gram length is independent
495
+ # since we don't track used_positions during processing
496
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
497
+ # Submit all tasks
498
+ future_to_n = {
499
+ executor.submit(
500
+ self._process_ngram_length_no_state,
501
+ n, trans_words, all_words, ref_texts_clean, ref_words, self.min_sources
502
+ ): n
503
+ for n in n_gram_lengths
504
+ }
505
+
506
+ completed = 0
507
+ for future in as_completed(future_to_n):
508
+ n = future_to_n[future]
509
+ completed += 1
510
+
511
+ # Check timeout periodically
512
+ if self.timeout_seconds > 0:
513
+ elapsed_time = time.time() - start_time
514
+ if elapsed_time > self.timeout_seconds:
515
+ self.logger.warning(f"🔍 ANCHOR SEARCH: ⏰ Timeout reached, stopping ({completed}/{len(n_gram_lengths)} completed)")
516
+ # Cancel remaining futures
517
+ for f in future_to_n.keys():
518
+ f.cancel()
519
+ break
520
+
521
+ try:
522
+ anchors = future.result()
523
+ candidate_anchors.extend(anchors)
524
+ if completed % 20 == 0:
525
+ self.logger.debug(f"🔍 ANCHOR SEARCH: Progress {completed}/{len(n_gram_lengths)} lengths processed")
526
+ except Exception as e:
527
+ self.logger.warning(f"🔍 ANCHOR SEARCH: ⚠️ n-gram length {n} failed: {str(e)}")
528
+ else:
529
+ # Sequential fallback
530
+ self.logger.info(f"🔍 ANCHOR SEARCH: Starting sequential n-gram processing ({len(n_gram_lengths)} lengths)")
531
+
532
+ batch_size = 10
533
+ batch_results = []
534
+
535
+ for i, n in enumerate(n_gram_lengths):
536
+ try:
537
+ # Check timeout periodically
538
+ if self.timeout_seconds > 0:
539
+ elapsed_time = time.time() - start_time
540
+ if elapsed_time > self.timeout_seconds:
541
+ self.logger.warning(f"🔍 ANCHOR SEARCH: ⏰ Timeout reached at n-gram {n}, stopping")
542
+ break
543
+
544
+ anchors = self._process_ngram_length(
545
+ n, trans_words, all_words, ref_texts_clean, ref_words, self.min_sources
546
+ )
547
+ candidate_anchors.extend(anchors)
548
+
549
+ # Batch logging
550
+ batch_results.append((n, len(anchors)))
551
+
552
+ # Log progress every batch_size results or on the last result
553
+ if (i + 1) % batch_size == 0 or (i + 1) == len(n_gram_lengths):
554
+ total_anchors_in_batch = sum(anchor_count for _, anchor_count in batch_results)
555
+ n_gram_ranges = [str(ng) for ng, _ in batch_results]
556
+ range_str = f"{n_gram_ranges[0]}-{n_gram_ranges[-1]}" if len(n_gram_ranges) > 1 else n_gram_ranges[0]
557
+ self.logger.debug(f"🔍 ANCHOR SEARCH: Completed n-gram lengths {range_str} - found {total_anchors_in_batch} anchors")
558
+ batch_results = []
559
+
560
+ except Exception as e:
561
+ self.logger.warning(f"🔍 ANCHOR SEARCH: ⚠️ n-gram length {n} failed: {str(e)}")
562
+ batch_results.append((n, 0))
563
+ continue
450
564
 
451
565
  self.logger.info(f"🔍 ANCHOR SEARCH: ✅ Found {len(candidate_anchors)} candidate anchors in {time.time() - start_time:.1f}s")
452
566
 
@@ -1,6 +1,7 @@
1
1
  from typing import List, Tuple, Dict, Any, Optional
2
2
  import spacy
3
3
  import logging
4
+ import time
4
5
  import pyphen
5
6
  import nltk
6
7
  from nltk.corpus import cmudict
@@ -11,6 +12,15 @@ from lyrics_transcriber.types import GapSequence, WordCorrection
11
12
  from lyrics_transcriber.correction.handlers.base import GapCorrectionHandler
12
13
  from lyrics_transcriber.correction.handlers.word_operations import WordOperations
13
14
 
15
+ # Try to import preloaders (may not exist in standalone library usage)
16
+ try:
17
+ from backend.services.spacy_preloader import get_preloaded_model
18
+ from backend.services.nltk_preloader import get_preloaded_cmudict
19
+
20
+ _HAS_PRELOADER = True
21
+ except ImportError:
22
+ _HAS_PRELOADER = False
23
+
14
24
 
15
25
  class SyllablesMatchHandler(GapCorrectionHandler):
16
26
  """Handles gaps where number of syllables in reference text matches number of syllables in transcription."""
@@ -18,11 +28,27 @@ class SyllablesMatchHandler(GapCorrectionHandler):
18
28
  def __init__(self, logger: Optional[logging.Logger] = None):
19
29
  super().__init__(logger)
20
30
  self.logger = logger or logging.getLogger(__name__)
31
+ init_start = time.time()
21
32
 
22
33
  # Marking SpacySyllables as used to prevent unused import warning
23
34
  _ = SpacySyllables
24
35
 
25
- # Load spacy model with syllables pipeline
36
+ # Try to use preloaded model first (avoids 60+ second load on Cloud Run)
37
+ if _HAS_PRELOADER:
38
+ preloaded = get_preloaded_model("en_core_web_sm")
39
+ if preloaded is not None:
40
+ self.logger.info("Using preloaded SpaCy model for syllable analysis")
41
+ self.nlp = preloaded
42
+ # Add syllables component if not already present
43
+ if "syllables" not in self.nlp.pipe_names:
44
+ self.nlp.add_pipe("syllables", after="tagger")
45
+ self._init_nltk_resources()
46
+ init_elapsed = time.time() - init_start
47
+ self.logger.info(f"Initialized SyllablesMatchHandler in {init_elapsed:.2f}s (preloaded)")
48
+ return
49
+
50
+ # Fall back to loading model directly
51
+ self.logger.info("Loading SpaCy model for syllable analysis (not preloaded)...")
26
52
  try:
27
53
  self.nlp = spacy.load("en_core_web_sm")
28
54
  except OSError:
@@ -43,10 +69,26 @@ class SyllablesMatchHandler(GapCorrectionHandler):
43
69
  if "syllables" not in self.nlp.pipe_names:
44
70
  self.nlp.add_pipe("syllables", after="tagger")
45
71
 
72
+ self._init_nltk_resources()
73
+ init_elapsed = time.time() - init_start
74
+ self.logger.info(f"Initialized SyllablesMatchHandler in {init_elapsed:.2f}s (lazy loaded)")
75
+
76
+ def _init_nltk_resources(self):
77
+ """Initialize NLTK resources (Pyphen and CMU dictionary)."""
78
+
46
79
  # Initialize Pyphen for English
47
80
  self.dic = pyphen.Pyphen(lang="en_US")
48
81
 
49
- # Initialize NLTK's CMU dictionary
82
+ # Try to use preloaded cmudict first (avoids 50-100+ second download on Cloud Run)
83
+ if _HAS_PRELOADER:
84
+ preloaded_cmudict = get_preloaded_cmudict()
85
+ if preloaded_cmudict is not None:
86
+ self.logger.debug("Using preloaded NLTK cmudict")
87
+ self.cmudict = preloaded_cmudict
88
+ return
89
+
90
+ # Fall back to loading directly
91
+ self.logger.info("Loading NLTK cmudict (not preloaded)...")
50
92
  try:
51
93
  self.cmudict = cmudict.dict()
52
94
  except LookupError:
@@ -5,6 +5,14 @@ import logging
5
5
  from lyrics_transcriber.correction.text_utils import clean_text
6
6
  from lyrics_transcriber.types import PhraseType, PhraseScore
7
7
 
8
+ # Try to import preloader (may not exist in standalone library usage)
9
+ try:
10
+ from backend.services.spacy_preloader import get_preloaded_model
11
+
12
+ _HAS_PRELOADER = True
13
+ except ImportError:
14
+ _HAS_PRELOADER = False
15
+
8
16
 
9
17
  class PhraseAnalyzer:
10
18
  """Language-agnostic phrase analyzer using spaCy"""
@@ -17,6 +25,16 @@ class PhraseAnalyzer:
17
25
  language_code: spaCy language model to use
18
26
  """
19
27
  self.logger = logger
28
+
29
+ # Try to use preloaded model first (avoids 60+ second load on Cloud Run)
30
+ if _HAS_PRELOADER:
31
+ preloaded = get_preloaded_model(language_code)
32
+ if preloaded is not None:
33
+ self.logger.info(f"Using preloaded SpaCy model: {language_code}")
34
+ self.nlp = preloaded
35
+ return
36
+
37
+ # Fall back to loading model directly
20
38
  self.logger.info(f"Initializing PhraseAnalyzer with language model: {language_code}")
21
39
  try:
22
40
  self.nlp = spacy.load(language_code)
@@ -6,7 +6,7 @@ export interface ApiClient {
6
6
  getCorrectionData: () => Promise<CorrectionData>;
7
7
  submitCorrections: (data: CorrectionData) => Promise<void>;
8
8
  getAudioUrl: (audioHash: string) => string;
9
- generatePreviewVideo: (data: CorrectionData) => Promise<PreviewVideoResponse>;
9
+ generatePreviewVideo: (data: CorrectionData, options?: PreviewOptions) => Promise<PreviewVideoResponse>;
10
10
  getPreviewVideoUrl: (previewHash: string) => string;
11
11
  updateHandlers: (enabledHandlers: string[]) => Promise<CorrectionData>;
12
12
  isUpdatingHandlers?: boolean;
@@ -21,6 +21,11 @@ interface CorrectionUpdate {
21
21
  corrected_segments: CorrectionData['corrected_segments'];
22
22
  }
23
23
 
24
+ // Add interface for preview generation options
25
+ export interface PreviewOptions {
26
+ use_background_image?: boolean;
27
+ }
28
+
24
29
  // Add new interface for preview response
25
30
  interface PreviewVideoResponse {
26
31
  status: "success" | "error";
@@ -96,11 +101,13 @@ export class LiveApiClient implements ApiClient {
96
101
  return this.buildUrl(`/audio/${audioHash}`)
97
102
  }
98
103
 
99
- async generatePreviewVideo(data: CorrectionData): Promise<PreviewVideoResponse> {
104
+ async generatePreviewVideo(data: CorrectionData, options?: PreviewOptions): Promise<PreviewVideoResponse> {
100
105
  // Extract only the needed fields, just like in submitCorrections
101
- const updatePayload: CorrectionUpdate = {
106
+ // Include use_background_image option (defaults to false for fast black background)
107
+ const updatePayload = {
102
108
  corrections: data.corrections,
103
- corrected_segments: data.corrected_segments
109
+ corrected_segments: data.corrected_segments,
110
+ use_background_image: options?.use_background_image ?? false,
104
111
  };
105
112
 
106
113
  const response = await fetch(this.buildUrl('/preview-video'), {
@@ -224,7 +231,8 @@ export class FileOnlyClient implements ApiClient {
224
231
  throw new Error('Not supported in file-only mode');
225
232
  }
226
233
 
227
- async generatePreviewVideo(): Promise<PreviewVideoResponse> {
234
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
235
+ async generatePreviewVideo(_data: CorrectionData, _options?: PreviewOptions): Promise<PreviewVideoResponse> {
228
236
  throw new Error('Not supported in file-only mode');
229
237
  }
230
238
 
@@ -1,5 +1,5 @@
1
- import { Box, Typography, CircularProgress, Alert, Button } from '@mui/material'
2
- import { useState, useEffect } from 'react'
1
+ import { Box, Typography, CircularProgress, Alert, Button, FormControlLabel, Checkbox } from '@mui/material'
2
+ import { useState, useEffect, useCallback } from 'react'
3
3
  import { ApiClient } from '../api'
4
4
  import { CorrectionData } from '../types'
5
5
  import { applyOffsetToCorrectionData } from './shared/utils/timingUtils'
@@ -25,75 +25,108 @@ export default function PreviewVideoSection({
25
25
  error?: string;
26
26
  }>({ status: 'loading' });
27
27
 
28
- // Generate preview when modal opens
29
- useEffect(() => {
30
- if (isModalOpen && apiClient) {
31
- const generatePreview = async () => {
32
- setPreviewState({ status: 'loading' });
33
- try {
34
- // Debug logging for timing offset
35
- console.log(`[TIMING] PreviewVideoSection - Current timing offset: ${timingOffsetMs}ms`);
36
-
37
- // Apply timing offset if needed
38
- const dataToPreview = timingOffsetMs !== 0
39
- ? applyOffsetToCorrectionData(updatedData, timingOffsetMs)
40
- : updatedData;
41
-
42
- // Log some example timestamps after potential offset application
43
- if (dataToPreview.corrected_segments.length > 0) {
44
- const firstSegment = dataToPreview.corrected_segments[0];
45
- console.log(`[TIMING] Preview - First segment id: ${firstSegment.id}`);
46
- console.log(`[TIMING] - start_time: ${firstSegment.start_time}, end_time: ${firstSegment.end_time}`);
47
-
48
- if (firstSegment.words.length > 0) {
49
- const firstWord = firstSegment.words[0];
50
- console.log(`[TIMING] - first word "${firstWord.text}" time: ${firstWord.start_time} -> ${firstWord.end_time}`);
51
- }
52
- }
53
-
54
- const response = await apiClient.generatePreviewVideo(dataToPreview);
55
-
56
- if (response.status === 'error') {
57
- setPreviewState({
58
- status: 'error',
59
- error: response.message || 'Failed to generate preview video'
60
- });
61
- return;
62
- }
28
+ // Toggle for rendering with theme background image (slower) vs black background (faster)
29
+ const [useBackgroundImage, setUseBackgroundImage] = useState(false);
63
30
 
64
- if (!response.preview_hash) {
65
- setPreviewState({
66
- status: 'error',
67
- error: 'No preview hash received from server'
68
- });
69
- return;
70
- }
31
+ // Memoized function to generate preview
32
+ const generatePreview = useCallback(async () => {
33
+ if (!apiClient) return;
34
+
35
+ setPreviewState({ status: 'loading' });
36
+ try {
37
+ // Debug logging for timing offset
38
+ console.log(`[TIMING] PreviewVideoSection - Current timing offset: ${timingOffsetMs}ms`);
39
+ console.log(`[PREVIEW] Using background image: ${useBackgroundImage}`);
40
+
41
+ // Apply timing offset if needed
42
+ const dataToPreview = timingOffsetMs !== 0
43
+ ? applyOffsetToCorrectionData(updatedData, timingOffsetMs)
44
+ : updatedData;
71
45
 
72
- const videoUrl = apiClient.getPreviewVideoUrl(response.preview_hash);
73
- setPreviewState({
74
- status: 'ready',
75
- videoUrl
76
- });
77
- } catch (error) {
78
- setPreviewState({
79
- status: 'error',
80
- error: (error as Error).message || 'Failed to generate preview video'
81
- });
46
+ // Log some example timestamps after potential offset application
47
+ if (dataToPreview.corrected_segments.length > 0) {
48
+ const firstSegment = dataToPreview.corrected_segments[0];
49
+ console.log(`[TIMING] Preview - First segment id: ${firstSegment.id}`);
50
+ console.log(`[TIMING] - start_time: ${firstSegment.start_time}, end_time: ${firstSegment.end_time}`);
51
+
52
+ if (firstSegment.words.length > 0) {
53
+ const firstWord = firstSegment.words[0];
54
+ console.log(`[TIMING] - first word "${firstWord.text}" time: ${firstWord.start_time} -> ${firstWord.end_time}`);
82
55
  }
83
- };
56
+ }
57
+
58
+ const response = await apiClient.generatePreviewVideo(dataToPreview, {
59
+ use_background_image: useBackgroundImage
60
+ });
84
61
 
62
+ if (response.status === 'error') {
63
+ setPreviewState({
64
+ status: 'error',
65
+ error: response.message || 'Failed to generate preview video'
66
+ });
67
+ return;
68
+ }
69
+
70
+ if (!response.preview_hash) {
71
+ setPreviewState({
72
+ status: 'error',
73
+ error: 'No preview hash received from server'
74
+ });
75
+ return;
76
+ }
77
+
78
+ const videoUrl = apiClient.getPreviewVideoUrl(response.preview_hash);
79
+ setPreviewState({
80
+ status: 'ready',
81
+ videoUrl
82
+ });
83
+ } catch (error) {
84
+ setPreviewState({
85
+ status: 'error',
86
+ error: (error as Error).message || 'Failed to generate preview video'
87
+ });
88
+ }
89
+ }, [apiClient, updatedData, timingOffsetMs, useBackgroundImage]);
90
+
91
+ // Generate preview when modal opens or when background toggle changes
92
+ useEffect(() => {
93
+ if (isModalOpen && apiClient) {
85
94
  generatePreview();
86
95
  }
87
- }, [isModalOpen, apiClient, updatedData, timingOffsetMs]);
96
+ }, [isModalOpen, apiClient, generatePreview]);
88
97
 
89
98
  if (!apiClient) return null;
90
99
 
91
100
  return (
92
101
  <Box sx={{ mb: 2 }}>
102
+ {/* Background image toggle */}
103
+ <Box sx={{ px: 2, pt: 2, pb: 1 }}>
104
+ <FormControlLabel
105
+ control={
106
+ <Checkbox
107
+ checked={useBackgroundImage}
108
+ onChange={(e) => setUseBackgroundImage(e.target.checked)}
109
+ disabled={previewState.status === 'loading'}
110
+ size="small"
111
+ />
112
+ }
113
+ label={
114
+ <Box>
115
+ <Typography variant="body2" component="span">
116
+ Render with theme background
117
+ </Typography>
118
+ <Typography variant="caption" color="text.secondary" display="block">
119
+ Preview uses black background for speed (~10s). Enable for theme background (~30-60s).
120
+ </Typography>
121
+ </Box>
122
+ }
123
+ />
124
+ </Box>
125
+
93
126
  {previewState.status === 'loading' && (
94
127
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
95
128
  <CircularProgress size={24} />
96
- <Typography>Generating preview video...</Typography>
129
+ <Typography>Generating preview video{useBackgroundImage ? ' with theme background' : ''}...</Typography>
97
130
  </Box>
98
131
  )}
99
132