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.
- backend/api/routes/admin.py +696 -92
- backend/api/routes/audio_search.py +29 -8
- backend/api/routes/file_upload.py +99 -22
- backend/api/routes/health.py +65 -0
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +28 -1
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +472 -51
- backend/main.py +31 -2
- backend/middleware/__init__.py +7 -1
- backend/middleware/tenant.py +192 -0
- backend/models/job.py +19 -3
- backend/models/tenant.py +208 -0
- backend/models/user.py +18 -0
- backend/services/email_service.py +253 -6
- backend/services/encoding_service.py +128 -31
- backend/services/firestore_service.py +6 -0
- backend/services/job_manager.py +44 -2
- backend/services/langfuse_preloader.py +98 -0
- backend/services/nltk_preloader.py +122 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/stripe_service.py +133 -11
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- backend/tests/emulator/conftest.py +22 -1
- backend/tests/emulator/test_made_for_you_integration.py +167 -0
- backend/tests/test_admin_job_files.py +337 -0
- backend/tests/test_admin_job_reset.py +384 -0
- backend/tests/test_admin_job_update.py +326 -0
- backend/tests/test_email_service.py +233 -0
- backend/tests/test_impersonation.py +223 -0
- backend/tests/test_job_creation_regression.py +4 -0
- backend/tests/test_job_manager.py +171 -9
- backend/tests/test_jobs_api.py +11 -1
- backend/tests/test_made_for_you.py +2086 -0
- backend/tests/test_models.py +139 -0
- backend/tests/test_spacy_preloader.py +119 -0
- backend/tests/test_tenant_api.py +350 -0
- backend/tests/test_tenant_middleware.py +345 -0
- backend/tests/test_tenant_models.py +406 -0
- backend/tests/test_tenant_service.py +418 -0
- backend/utils/test_data.py +27 -0
- backend/workers/screens_worker.py +16 -6
- backend/workers/video_worker.py +8 -3
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +58 -39
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- lyrics_transcriber/frontend/src/api.ts +13 -5
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
29
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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,
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|