MazCodes commited on
Commit
72f7156
·
verified ·
1 Parent(s): 44978ad

Upload folder using huggingface_hub

Browse files
app/backend/app.py CHANGED
@@ -58,13 +58,6 @@ def request_entity_too_large(error):
58
 
59
  DEBUG_MODE = os.environ.get('FRAGMENTA_DEBUG', 'false').lower() == 'true'
60
 
61
- # ---------------------------------------------------------------------------
62
- # Lazy-initialised backend components
63
- # ---------------------------------------------------------------------------
64
- # These are initialised on first real API request (not at import time) so that
65
- # the Flask server always starts — even when model files or heavy deps are
66
- # temporarily unavailable. The /api/health endpoint works unconditionally.
67
- # ---------------------------------------------------------------------------
68
  config = None
69
  audio_processor = None
70
  generator = None
@@ -74,7 +67,6 @@ _init_error = None
74
 
75
 
76
  def _ensure_components():
77
- """Initialise backend components on first use. Thread-safe."""
78
  global config, audio_processor, generator, model_manager
79
  global _components_initialised, _init_error
80
 
@@ -105,21 +97,18 @@ def _ensure_components():
105
 
106
  @app.before_request
107
  def lazy_init():
108
- """Initialise heavy components before the first real API call."""
109
  if request.path == '/api/health':
110
- return # health endpoint must always work
111
  try:
112
  _ensure_components()
113
  except Exception as e:
114
  if request.path.startswith('/api/'):
115
  return jsonify({'error': f'Backend not ready: {e}'}), 503
116
- # Static file / React routes — let them through even if init fails
117
  return None
118
 
119
 
120
  @app.route('/api/health')
121
  def health_check():
122
- """Health check endpoint — always available, even when components fail."""
123
  import torch
124
  status = {
125
  'status': 'ok' if _components_initialised else 'degraded',
@@ -128,9 +117,8 @@ def health_check():
128
  'gpu_available': torch.cuda.is_available(),
129
  'gpu_name': torch.cuda.get_device_name(0) if torch.cuda.is_available() else None,
130
  }
131
- code = 200 if _components_initialised else 503
132
  # Return 200 even in degraded mode so Docker HEALTHCHECK doesn't kill
133
- # the container before components finish loading
134
  return jsonify(status), 200
135
 
136
 
@@ -208,18 +196,13 @@ def process_files():
208
 
209
  chunks_preview_data = []
210
  for filename, prompt in prompts_data:
211
- chunks_preview_data.append([
212
- filename, # Original filename (not chunked)
213
- filename, # Source file
214
- prompt, # User's prompt
215
- "original" # Not chunked
216
- ])
217
-
218
- # Do not overwrite the metadata! keeps dataset creation more sustainable
219
  json_path = Path(config.get_metadata_json_path())
220
  existing_metadata = []
221
-
222
- # Load existing metadata if file exists
223
  if json_path.exists():
224
  try:
225
  with open(json_path, 'r', encoding='utf-8') as f:
@@ -254,7 +237,7 @@ def process_files():
254
  'message': f'Files saved successfully! {len(saved_files)} original files saved to data folder',
255
  'saved_files': saved_files,
256
  'processed_count': len(saved_files),
257
- 'chunks_preview': chunks_preview_data, # Show all files (no chunking)
258
  'data_folder': str(data_dir),
259
  'metadata_json': str(json_path),
260
  'approach': 'original_files_only'
@@ -366,7 +349,7 @@ def generate_audio():
366
  config_file = None
367
  model_file_path = None
368
 
369
- # Priority: unwrapped_model_path > model_path > base model
370
  if unwrapped_model_path:
371
  model_file_path = Path(unwrapped_model_path)
372
  if not model_file_path.exists():
@@ -381,7 +364,7 @@ def generate_audio():
381
  f"model_path:{model_name}", str(model_file_path))
382
  logger.debug(f"Using model path: {model_file_path}")
383
 
384
- # Determine config based on file size or model name
385
  if model_file_path:
386
  file_size_gb = model_file_path.stat().st_size / (1024**3)
387
  config_file = "model_config_small.json" if file_size_gb < 2.0 else "model_config.json"
@@ -402,7 +385,6 @@ def generate_audio():
402
  logger.info(f"Starting generation with config: {config_file}")
403
  try:
404
  if determined_model_path and determined_model_path.exists():
405
- # Use the determined model path
406
  output_path = generator.generate_audio(
407
  prompt,
408
  unwrapped_model_path=unwrapped_model_path if unwrapped_model_path else None,
@@ -411,7 +393,6 @@ def generate_audio():
411
  duration=duration
412
  )
413
  elif model_name in ['stable-audio-open-small', 'stable-audio-open-1.0']:
414
- # Handle base models
415
  model_file_mapping = {
416
  'stable-audio-open-small': 'stable-audio-open-small-model.safetensors',
417
  'stable-audio-open-1.0': 'stable-audio-open-model.safetensors'
@@ -548,10 +529,8 @@ def get_models():
548
  has_checkpoint = len(checkpoint_files) > 0
549
  has_config = len(config_files) > 0
550
 
551
- # Create detailed checkpoint information
552
  checkpoints = []
553
  for ckpt_file in checkpoint_files:
554
- # Extract epoch and step from filename if possible
555
  import re
556
  name = ckpt_file.stem
557
  epoch_match = re.search(r'epoch=(\d+)', name)
@@ -559,7 +538,6 @@ def get_models():
559
 
560
  checkpoint_info = {
561
  'name': name,
562
- # Use relative path
563
  'path': str(ckpt_file.relative_to(config.project_root)),
564
  'size_mb': round(ckpt_file.stat().st_size / (1024 * 1024), 1),
565
  'created': ckpt_file.stat().st_mtime
@@ -572,45 +550,38 @@ def get_models():
572
 
573
  checkpoints.append(checkpoint_info)
574
 
575
- # Sort checkpoints by creation time (newest first)
576
  checkpoints.sort(key=lambda x: x['created'], reverse=True)
577
 
578
- # Get the latest checkpoint and config files
579
  latest_checkpoint = max(checkpoint_files, key=lambda x: x.stat(
580
  ).st_mtime) if checkpoint_files else None
581
  latest_config = max(
582
  config_files, key=lambda x: x.stat().st_mtime) if config_files else None
583
 
584
- # Check for unwrapped models
585
  unwrapped_dir = model_dir / "unwrapped"
586
  unwrapped_models = []
587
  if unwrapped_dir.exists():
588
  for unwrapped_file in unwrapped_dir.glob("*.safetensors"):
589
  unwrapped_models.append({
590
  'name': unwrapped_file.stem,
591
- # Use relative path
592
  'path': str(unwrapped_file.relative_to(config.project_root)),
593
  'size_mb': round(unwrapped_file.stat().st_size / (1024 * 1024), 1),
594
  'created': unwrapped_file.stat().st_mtime
595
  })
596
 
597
- # Sort unwrapped models by creation time (newest first)
598
  unwrapped_models.sort(
599
  key=lambda x: x['created'], reverse=True)
600
 
601
- # For fine-tuned models, use the base model's config
602
- base_config_path = "models/config/model_config_small.json" # Use relative path
603
 
604
  models.append({
605
  'name': model_dir.name,
606
- # Use relative path
607
  'path': str(model_dir.relative_to(config.project_root)),
608
  'has_checkpoint': has_checkpoint,
609
  'has_config': has_config,
610
- # Use relative path
611
  'ckpt_path': str(latest_checkpoint.relative_to(config.project_root)) if latest_checkpoint else None,
612
- 'config_path': base_config_path, # Use base model config for unwrapping
613
- 'checkpoints': checkpoints, # Detailed checkpoint list
614
  'unwrapped_models': unwrapped_models,
615
  'created': model_dir.stat().st_mtime if model_dir.exists() else None
616
  })
@@ -622,7 +593,6 @@ def get_models():
622
 
623
  @app.route('/api/models/available', methods=['GET'])
624
  def get_available_models():
625
- """Get list of available models from Hugging Face"""
626
  try:
627
  models = model_manager.get_available_models()
628
  return jsonify({'models': models})
@@ -632,7 +602,6 @@ def get_available_models():
632
 
633
  @app.route('/api/models/<model_id>/info', methods=['GET'])
634
  def get_model_info(model_id):
635
- """Get information about a specific model"""
636
  try:
637
  model_info = model_manager.get_model_info(model_id)
638
  if not model_info:
@@ -644,7 +613,6 @@ def get_model_info(model_id):
644
 
645
  @app.route('/api/models/<model_id>/accept-terms', methods=['POST'])
646
  def accept_model_terms(model_id):
647
- """Accept terms for a specific model"""
648
  try:
649
  success = model_manager.accept_terms(model_id)
650
  if success:
@@ -657,13 +625,10 @@ def accept_model_terms(model_id):
657
 
658
  @app.route('/api/models/<model_id>/download', methods=['POST'])
659
  def download_model(model_id):
660
- """Download a model from Hugging Face"""
661
  try:
662
- # Check if terms are accepted
663
  if not model_manager.is_terms_accepted(model_id):
664
  return jsonify({'error': 'Terms not accepted for this model'}), 400
665
 
666
- # Start download
667
  success = model_manager.download_model(model_id)
668
  if success:
669
  return jsonify({
@@ -678,7 +643,6 @@ def download_model(model_id):
678
 
679
  @app.route('/api/hf-login', methods=['POST'])
680
  def hf_login():
681
- """Login to Hugging Face with a token"""
682
  try:
683
  data = request.json
684
  token = data.get('token')
@@ -698,36 +662,33 @@ def hf_login():
698
 
699
  @app.route('/api/base-models/status', methods=['GET'])
700
  def get_base_models_status():
701
- """Get the download status of base models"""
702
  try:
703
  import os
704
  from pathlib import Path
705
-
706
  base_models = {
707
  'stable-audio-open-1.0': {
708
  'name': 'Stable Audio Open 1.0',
709
- 'path': 'models/pretrained', # Updated to correct path
710
- 'file': 'stable-audio-open-model.safetensors', # Specific file to check
711
  'downloaded': False
712
  },
713
  'stable-audio-open-small': {
714
- 'name': 'Stable Audio Open Small',
715
- 'path': 'models/pretrained', # Updated to correct path
716
- 'file': 'stable-audio-open-small-model.safetensors', # Specific file to check
717
  'downloaded': False
718
  }
719
  }
720
-
721
- # Check if models are actually downloaded by looking for specific files
722
  for model_id, info in base_models.items():
723
  model_dir = Path(info['path'])
724
  model_file = model_dir / info['file']
725
-
726
- # Check if the specific model file exists
727
  if model_file.exists() and model_file.is_file():
728
  info['downloaded'] = True
729
  else:
730
- # Fallback: check subdirectory structure (old format)
731
  old_path = model_dir / model_id
732
  if old_path.exists() and old_path.is_dir():
733
  has_files = any([
@@ -746,7 +707,6 @@ def get_base_models_status():
746
 
747
  @app.route('/api/models/<model_id>/delete', methods=['DELETE'])
748
  def delete_model(model_id):
749
- """Delete a downloaded model"""
750
  try:
751
  success = model_manager.delete_model(model_id)
752
  if success:
@@ -759,7 +719,6 @@ def delete_model(model_id):
759
 
760
  @app.route('/api/models/storage', methods=['GET'])
761
  def get_model_storage():
762
- """Get storage information for models"""
763
  try:
764
  storage_info = model_manager.get_storage_info()
765
  return jsonify(storage_info)
@@ -769,21 +728,18 @@ def get_model_storage():
769
 
770
  @app.route('/api/start-fresh', methods=['POST'])
771
  def start_fresh():
772
- """Delete all data and start fresh"""
773
  try:
774
  config = get_config()
775
  data_dir = config.get_path("data")
776
  config_dir = config.get_path("models_config")
777
 
778
- # Delete all data files
779
  data_files_deleted = 0
780
  if data_dir.exists():
781
  for file_path in data_dir.glob("*"):
782
- if file_path.is_file() and not file_path.name.endswith('.py'): # Don't delete Python files
783
  file_path.unlink()
784
  data_files_deleted += 1
785
 
786
- # Delete config metadata files (but keep the model configs)
787
  config_files_deleted = 0
788
  if config_dir.exists():
789
  for file_path in config_dir.glob("custom_metadata.py"):
@@ -791,7 +747,6 @@ def start_fresh():
791
  file_path.unlink()
792
  config_files_deleted += 1
793
 
794
- # Recreate empty data directory
795
  data_dir.mkdir(exist_ok=True, parents=True)
796
 
797
  return jsonify({
@@ -806,7 +761,6 @@ def start_fresh():
806
 
807
  @app.route('/api/unwrap-model', methods=['POST'])
808
  def unwrap_model():
809
- """Unwrap a specific model checkpoint"""
810
  try:
811
  data = request.json
812
  model_config = data.get('model_config')
@@ -816,34 +770,28 @@ def unwrap_model():
816
  if not model_config or not ckpt_path:
817
  return jsonify({'error': 'model_config and ckpt_path are required'}), 400
818
 
819
- # Use the stable-audio-tools unwrap_model.py script directly for individual checkpoints
820
  import subprocess
821
  from pathlib import Path
822
 
823
- # Get config to resolve relative paths
824
  config = get_config()
825
  repo_root = config.project_root
826
 
827
- # Resolve paths relative to project root
828
  model_config_path = repo_root / \
829
  model_config if not Path(
830
  model_config).is_absolute() else Path(model_config)
831
  ckpt_path_resolved = repo_root / \
832
  ckpt_path if not Path(ckpt_path).is_absolute() else Path(ckpt_path)
833
 
834
- # Validate paths exist
835
  if not model_config_path.exists():
836
  return jsonify({'error': f'Model config not found: {model_config_path}'}), 400
837
  if not ckpt_path_resolved.exists():
838
  return jsonify({'error': f'Checkpoint not found: {ckpt_path_resolved}'}), 400
839
 
840
- # Get the model directory and create unwrapped subdirectory
841
  model_dir = ckpt_path_resolved.parent
842
  unwrapped_dir = model_dir / "unwrapped"
843
  unwrapped_dir.mkdir(exist_ok=True)
844
 
845
  cmd = [
846
- # Just the script name since we're running from stable-audio-tools dir
847
  sys.executable, 'unwrap_model.py',
848
  '--model-config', str(model_config_path),
849
  '--ckpt-path', str(ckpt_path_resolved),
@@ -851,17 +799,14 @@ def unwrap_model():
851
  '--use-safetensors'
852
  ]
853
 
854
- # Run from repo root and set working directory to stable-audio-tools
855
  stable_audio_dir = repo_root / "stable-audio-tools"
856
 
857
  proc = subprocess.run(cmd, cwd=stable_audio_dir,
858
  capture_output=True, text=True)
859
 
860
  if proc.returncode == 0:
861
- # The unwrap_model.py script creates files in the stable-audio-tools directory
862
- # We need to move them to the correct unwrapped directory
863
 
864
- # Find the created file in stable-audio-tools directory
865
  import glob
866
  pattern = str(stable_audio_dir / f"{name}*.safetensors")
867
  created_files = glob.glob(pattern)
@@ -872,14 +817,12 @@ def unwrap_model():
872
  target_path = unwrapped_dir / created_path.name
873
 
874
  try:
875
- # Move the file to the unwrapped directory
876
  created_path.rename(target_path)
877
  moved_files.append(str(target_path))
878
  print(f"Moved {created_path.name} to {target_path}")
879
  except Exception as e:
880
  print(f"Error moving {created_path}: {e}")
881
 
882
- # Find all unwrapped files in the unwrapped directory
883
  unwrapped_files = list(unwrapped_dir.glob("*.safetensors"))
884
 
885
  return jsonify({
@@ -897,7 +840,6 @@ def unwrap_model():
897
 
898
  @app.route('/api/delete-checkpoint', methods=['POST'])
899
  def delete_checkpoint():
900
- """Delete a specific checkpoint file"""
901
  try:
902
  data = request.json
903
  checkpoint_path = data.get('checkpoint_path')
@@ -905,11 +847,9 @@ def delete_checkpoint():
905
  if not checkpoint_path:
906
  return jsonify({'error': 'checkpoint_path is required'}), 400
907
 
908
- # Get config to resolve relative paths
909
  config = get_config()
910
  repo_root = config.project_root
911
 
912
- # Resolve path relative to project root
913
  ckpt_path_resolved = repo_root / \
914
  checkpoint_path if not Path(
915
  checkpoint_path).is_absolute() else Path(checkpoint_path)
@@ -917,7 +857,7 @@ def delete_checkpoint():
917
  if not ckpt_path_resolved.exists():
918
  return jsonify({'error': f'Checkpoint file not found: {ckpt_path_resolved}'}), 404
919
 
920
- # Ensure it's a .ckpt file for safety
921
  if not ckpt_path_resolved.suffix == '.ckpt':
922
  return jsonify({'error': f'Only .ckpt files can be deleted: {ckpt_path_resolved}'}), 400
923
 
@@ -937,7 +877,6 @@ def delete_checkpoint():
937
 
938
  @app.route('/api/delete-wrapped-checkpoint', methods=['POST'])
939
  def delete_wrapped_checkpoint():
940
- """Delete wrapped checkpoint files for a specific model"""
941
  try:
942
  data = request.json
943
  model_name = data.get('model_name')
@@ -945,7 +884,6 @@ def delete_wrapped_checkpoint():
945
  if not model_name:
946
  return jsonify({'error': 'model_name is required'}), 400
947
 
948
- # Find the model directory
949
  config = get_config()
950
  models_dir = config.get_path("models_fine_tuned")
951
  model_dir = models_dir / model_name
@@ -953,7 +891,6 @@ def delete_wrapped_checkpoint():
953
  if not model_dir.exists():
954
  return jsonify({'error': f'Model directory not found: {model_dir}'}), 404
955
 
956
- # Find and delete wrapped checkpoint files (.ckpt)
957
  deleted_files = []
958
  for ckpt_file in model_dir.glob("*.ckpt"):
959
  try:
@@ -976,7 +913,6 @@ def delete_wrapped_checkpoint():
976
 
977
  @app.route('/api/free-gpu-memory', methods=['POST'])
978
  def free_gpu_memory():
979
- """Free GPU memory by clearing cache and stopping training processes"""
980
  try:
981
  import subprocess
982
  import torch
@@ -985,23 +921,18 @@ def free_gpu_memory():
985
 
986
  print(" FREEING GPU MEMORY...")
987
 
988
- # Clear PyTorch CUDA cache
989
  if torch.cuda.is_available():
990
  torch.cuda.empty_cache()
991
  print(" Cleared PyTorch CUDA cache")
992
 
993
- # Clear MPS cache if available
994
  if hasattr(torch, 'mps') and torch.backends.mps.is_available():
995
  torch.mps.empty_cache()
996
  print(" Cleared MPS cache")
997
 
998
- # Get current process ID to avoid killing ourselves
999
  current_pid = os.getpid()
1000
  print(f" Current process PID: {current_pid}")
1001
 
1002
- # Check for training processes and stop them safely
1003
  try:
1004
- # Get all CUDA processes
1005
  result = subprocess.run(['nvidia-smi', '--query-compute-apps=pid,used_memory,process_name', '--format=csv,noheader,nounits'],
1006
  capture_output=True, text=True, timeout=10)
1007
 
@@ -1016,32 +947,25 @@ def free_gpu_memory():
1016
  pid_int = int(pid)
1017
  mem_gb = float(mem_mb) / 1024
1018
 
1019
- # Skip our own process
1020
  if pid_int == current_pid:
1021
  print(
1022
  f" Skipping current process PID: {pid_int}")
1023
  continue
1024
 
1025
- # Check if it's a Python process using significant memory
1026
  if 'python' in process_name.lower() and mem_gb > 1.0:
1027
  print(
1028
  f" Found Python process PID: {pid_int} using {mem_gb:.1f}GB")
1029
  print(f" Process: {process_name}")
1030
 
1031
- # Try to gracefully stop the process
1032
  try:
1033
- # Send SIGTERM first (graceful)
1034
  subprocess.run(
1035
  ['kill', '-TERM', str(pid_int)], check=False, timeout=5)
1036
  print(
1037
  f" Sent SIGTERM to PID: {pid_int}")
1038
 
1039
- # Wait a moment
1040
  time.sleep(2)
1041
 
1042
- # Check if process is still running
1043
  try:
1044
- # Check if process exists
1045
  os.kill(pid_int, 0)
1046
  print(
1047
  f" Process {pid_int} still running, sending SIGKILL")
@@ -1069,18 +993,14 @@ def free_gpu_memory():
1069
  except Exception as e:
1070
  print(f" Could not check CUDA processes: {e}")
1071
 
1072
- # Wait a moment for processes to stop
1073
  time.sleep(3)
1074
 
1075
- # Clear cache again after stopping processes
1076
  if torch.cuda.is_available():
1077
  torch.cuda.empty_cache()
1078
  print(" Cleared PyTorch CUDA cache again")
1079
 
1080
- # Get memory info after clearing
1081
  memory_info = {}
1082
  if torch.cuda.is_available():
1083
- # Use the same improved memory detection as the status endpoint
1084
  total_memory = torch.cuda.get_device_properties(
1085
  0).total_memory / (1024**3)
1086
  torch.cuda.synchronize()
@@ -1088,7 +1008,6 @@ def free_gpu_memory():
1088
  cached_memory = torch.cuda.memory_reserved(0) / (1024**3)
1089
  free_memory = total_memory - allocated_memory
1090
 
1091
- # Get nvidia-smi info
1092
  try:
1093
  result = subprocess.run(['nvidia-smi', '--query-gpu=memory.used,memory.total', '--format=csv,noheader,nounits'],
1094
  capture_output=True, text=True, timeout=5)
@@ -1107,7 +1026,7 @@ def free_gpu_memory():
1107
  nvidia_total_gb = total_memory
1108
  nvidia_free_gb = total_memory
1109
 
1110
- # Use the most accurate reading
1111
  if allocated_memory > 0:
1112
  final_allocated = allocated_memory
1113
  final_free = free_memory
@@ -1146,7 +1065,6 @@ def free_gpu_memory():
1146
 
1147
  @app.route('/api/toggle-debug', methods=['POST'])
1148
  def toggle_debug():
1149
- """Toggle debug mode for GPU memory logging"""
1150
  global DEBUG_MODE
1151
  try:
1152
  data = request.json
@@ -1166,14 +1084,12 @@ def toggle_debug():
1166
 
1167
  @app.route('/api/debug-status', methods=['GET'])
1168
  def get_debug_status():
1169
- """Get current debug mode status"""
1170
  return jsonify({
1171
  'debug_mode': DEBUG_MODE,
1172
  'message': f"Debug mode is {'enabled' if DEBUG_MODE else 'disabled'}"
1173
  })
1174
 
1175
 
1176
- # Add API call statistics for debugging
1177
  _api_call_stats = {
1178
  'gpu_memory_status': 0,
1179
  'status': 0,
@@ -1183,18 +1099,15 @@ _api_call_stats = {
1183
 
1184
 
1185
  def _log_api_call(endpoint):
1186
- """Log API call for debugging"""
1187
  global _api_call_stats
1188
  _api_call_stats[endpoint] = _api_call_stats.get(endpoint, 0) + 1
1189
 
1190
- # Reset stats every hour
1191
  if time.time() - _api_call_stats['last_reset'] > 3600:
1192
  _api_call_stats = {endpoint: 1, 'last_reset': time.time()}
1193
 
1194
 
1195
  @app.route('/api/debug-stats', methods=['GET'])
1196
  def get_debug_stats():
1197
- """Get API call statistics for debugging"""
1198
  return jsonify({
1199
  'api_call_stats': _api_call_stats,
1200
  'uptime_hours': (time.time() - _api_call_stats['last_reset']) / 3600,
@@ -1206,19 +1119,16 @@ def get_debug_stats():
1206
  })
1207
 
1208
 
1209
- # Add caching for GPU memory status to reduce overhead
1210
  _gpu_memory_cache = {}
1211
  _gpu_memory_cache_time = 0
1212
- _gpu_memory_cache_duration = 2.0 # Cache for 2 seconds
1213
 
1214
- # Throttle memory warnings (only show every 30 seconds)
1215
  _last_memory_warning_time = 0
1216
- _memory_warning_interval = 30 # seconds
1217
 
1218
 
1219
  @app.route('/api/open-output-folder', methods=['POST'])
1220
  def open_output_folder():
1221
- """Open the output folder in the system file explorer"""
1222
  try:
1223
  import subprocess
1224
  import platform
@@ -1241,7 +1151,6 @@ def open_output_folder():
1241
 
1242
  @app.route('/api/open-documentation', methods=['POST'])
1243
  def open_documentation():
1244
- """Open selected public Fragmenta links in the system browser."""
1245
  try:
1246
  import webbrowser
1247
 
@@ -1272,12 +1181,10 @@ def open_documentation():
1272
  logger.error(f"Error opening documentation: {e}")
1273
  return jsonify({"success": False, "error": str(e)}), 500
1274
 
1275
- # Global flag for welcome page state
1276
  _welcome_page_closed = False
1277
 
1278
  @app.route('/api/welcome-page-closed', methods=['POST'])
1279
  def welcome_page_closed():
1280
- """Signal that the welcome page has been closed"""
1281
  global _welcome_page_closed
1282
  try:
1283
  _welcome_page_closed = True
@@ -1289,26 +1196,21 @@ def welcome_page_closed():
1289
 
1290
  @app.route('/api/welcome-page-status', methods=['GET'])
1291
  def get_welcome_page_status():
1292
- """Check if welcome page has been closed"""
1293
  global _welcome_page_closed
1294
  return jsonify({"closed": _welcome_page_closed})
1295
 
1296
  @app.route('/api/license-info', methods=['GET'])
1297
  def get_license_info():
1298
- """Get license and attribution information"""
1299
  try:
1300
  project_root = Path(__file__).parent.parent.parent
1301
-
1302
- # Read LICENSE file
1303
  license_file = project_root / "LICENSE"
1304
  license_text = ""
1305
  if license_file.exists():
1306
  with open(license_file, 'r', encoding='utf-8') as f:
1307
- # Read first 50 lines for summary
1308
  lines = f.readlines()[:50]
1309
  license_text = ''.join(lines)
1310
-
1311
- # Read NOTICE.md for attribution info
1312
  notice_file = project_root / "NOTICE.md"
1313
  notice_text = ""
1314
  if notice_file.exists():
@@ -1332,7 +1234,6 @@ def get_license_info():
1332
 
1333
  @app.route('/api/models-status', methods=['GET'])
1334
  def get_models_status():
1335
- """Check if required models exist and if auth dialog should be shown"""
1336
  try:
1337
  required_models = ['stable-audio-open-small', 'stable-audio-open-1.0']
1338
  downloaded_models = [
@@ -1377,11 +1278,9 @@ def get_models_status():
1377
 
1378
  @app.route('/api/gpu-memory-status', methods=['GET'])
1379
  def get_gpu_memory_status():
1380
- """Get current GPU memory status with caching to reduce overhead"""
1381
  _log_api_call('gpu_memory_status')
1382
  global _gpu_memory_cache, _gpu_memory_cache_time
1383
 
1384
- # Check cache first
1385
  current_time = time.time()
1386
  if current_time - _gpu_memory_cache_time < _gpu_memory_cache_duration:
1387
  return jsonify({'memory_info': _gpu_memory_cache})
@@ -1393,42 +1292,35 @@ def get_gpu_memory_status():
1393
 
1394
  memory_info = {}
1395
  if torch.cuda.is_available():
1396
- # Get PyTorch memory info with better tracking
1397
  total_memory = torch.cuda.get_device_properties(
1398
  0).total_memory / (1024**3)
1399
 
1400
- # Force PyTorch to synchronize before reading memory
1401
  torch.cuda.synchronize()
1402
  allocated_memory = torch.cuda.memory_allocated(0) / (1024**3)
1403
  cached_memory = torch.cuda.memory_reserved(0) / (1024**3)
1404
  free_memory = total_memory - allocated_memory
1405
 
1406
- # Get nvidia-smi info for comparison (only if PyTorch shows 0 usage)
1407
  nvidia_used_gb = 0
1408
  nvidia_total_gb = total_memory
1409
  nvidia_free_gb = total_memory
1410
 
 
1411
  if allocated_memory == 0:
1412
  try:
1413
  result = subprocess.run(['nvidia-smi', '--query-gpu=memory.used,memory.total', '--format=csv,noheader,nounits'],
1414
- capture_output=True, text=True, timeout=1) # Add timeout
1415
  if result.stdout.strip():
1416
  used_mb, total_mb = result.stdout.strip().split(', ')
1417
  nvidia_used_gb = float(used_mb) / 1024
1418
  nvidia_total_gb = float(total_mb) / 1024
1419
  nvidia_free_gb = nvidia_total_gb - nvidia_used_gb
1420
  except Exception as e:
1421
- # Only log if there's an actual error, not just missing nvidia-smi
1422
  if "Could not get nvidia-smi info" not in str(e):
1423
  print(f"GPU Memory Error: {e}")
1424
 
1425
- # Get CUDA capability and device info
1426
  cuda_capability = torch.cuda.get_device_capability(0)
1427
  device_name = torch.cuda.get_device_name(0)
1428
 
1429
- # Use the most accurate memory reading
1430
- # If PyTorch shows 0 but nvidia-smi shows usage, use nvidia-smi
1431
- # If PyTorch shows usage, use PyTorch
1432
  if allocated_memory > 0:
1433
  final_allocated = allocated_memory
1434
  final_cached = cached_memory
@@ -1436,7 +1328,7 @@ def get_gpu_memory_status():
1436
  memory_source = "PyTorch"
1437
  else:
1438
  final_allocated = nvidia_used_gb
1439
- final_cached = cached_memory # Keep PyTorch cached
1440
  final_free = nvidia_free_gb
1441
  memory_source = "nvidia-smi"
1442
 
@@ -1453,19 +1345,17 @@ def get_gpu_memory_status():
1453
  'nvidia_used': nvidia_used_gb
1454
  }
1455
 
1456
- # Only log if there are significant issues AND enough time has passed
1457
  global _last_memory_warning_time
1458
  if (current_time - _last_memory_warning_time) > _memory_warning_interval:
1459
- if final_allocated > 10.0: # More than 10GB used
1460
  print(
1461
  f" High GPU Memory Usage: {final_allocated:.2f}GB allocated, {final_free:.2f}GB free")
1462
  _last_memory_warning_time = current_time
1463
- elif final_free < 1.0: # Less than 1GB free
1464
  print(
1465
  f" Low GPU Memory: {final_free:.2f}GB free, {final_allocated:.2f}GB allocated")
1466
  _last_memory_warning_time = current_time
1467
  else:
1468
- # CPU fallback
1469
  memory_info['cpu'] = {
1470
  'total': psutil.virtual_memory().total / (1024**3),
1471
  'available': psutil.virtual_memory().available / (1024**3),
@@ -1474,7 +1364,6 @@ def get_gpu_memory_status():
1474
  'type': 'cpu'
1475
  }
1476
 
1477
- # Update cache
1478
  _gpu_memory_cache = memory_info
1479
  _gpu_memory_cache_time = current_time
1480
 
@@ -1484,9 +1373,6 @@ def get_gpu_memory_status():
1484
  return jsonify({'error': str(e)}), 500
1485
 
1486
 
1487
- # ---------------------------------------------------------------------------
1488
- # Bulk auto-annotation
1489
- # ---------------------------------------------------------------------------
1490
  _annotate_job_lock = threading.Lock()
1491
  _annotate_job = {
1492
  'state': 'idle', # idle | running | done | error
@@ -1514,9 +1400,64 @@ def _clap_ckpt_path():
1514
  return clap_checkpoint_path(get_config().get_path('models_pretrained'))
1515
 
1516
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1517
  @app.route('/api/pick-folder', methods=['POST'])
1518
  def pick_folder():
1519
- """Open a native folder-picker dialog on the host and return the chosen path."""
1520
  import subprocess
1521
  import shutil as _shutil
1522
 
@@ -1747,10 +1688,8 @@ def bulk_annotate_unload_clap():
1747
 
1748
  @app.route('/shutdown', methods=['POST'])
1749
  def shutdown():
1750
- """Shutdown the Flask server gracefully"""
1751
  try:
1752
  print(" Shutting down Flask server...")
1753
- # Use a function to shutdown the server
1754
  func = request.environ.get('werkzeug.server.shutdown')
1755
  if func is None:
1756
  raise RuntimeError('Not running with the Werkzeug Server')
@@ -1761,7 +1700,6 @@ def shutdown():
1761
 
1762
 
1763
  if __name__ == '__main__':
1764
- # 0.0.0.0: reachable at this machine's LAN/Tailscale IPs (e.g. http://100.122.31.32:5001).
1765
  host = os.environ.get('FLASK_HOST', '0.0.0.0')
1766
  port = int(os.environ.get('FLASK_PORT', '5001'))
1767
  app.run(debug=True, host=host, port=port)
 
58
 
59
  DEBUG_MODE = os.environ.get('FRAGMENTA_DEBUG', 'false').lower() == 'true'
60
 
 
 
 
 
 
 
 
61
  config = None
62
  audio_processor = None
63
  generator = None
 
67
 
68
 
69
  def _ensure_components():
 
70
  global config, audio_processor, generator, model_manager
71
  global _components_initialised, _init_error
72
 
 
97
 
98
  @app.before_request
99
  def lazy_init():
 
100
  if request.path == '/api/health':
101
+ return
102
  try:
103
  _ensure_components()
104
  except Exception as e:
105
  if request.path.startswith('/api/'):
106
  return jsonify({'error': f'Backend not ready: {e}'}), 503
 
107
  return None
108
 
109
 
110
  @app.route('/api/health')
111
  def health_check():
 
112
  import torch
113
  status = {
114
  'status': 'ok' if _components_initialised else 'degraded',
 
117
  'gpu_available': torch.cuda.is_available(),
118
  'gpu_name': torch.cuda.get_device_name(0) if torch.cuda.is_available() else None,
119
  }
 
120
  # Return 200 even in degraded mode so Docker HEALTHCHECK doesn't kill
121
+ # the container before components finish loading.
122
  return jsonify(status), 200
123
 
124
 
 
196
 
197
  chunks_preview_data = []
198
  for filename, prompt in prompts_data:
199
+ chunks_preview_data.append([filename, filename, prompt, "original"])
200
+
201
+ # Merge into existing metadata instead of overwriting, so repeated
202
+ # uploads accumulate into one dataset.
 
 
 
 
203
  json_path = Path(config.get_metadata_json_path())
204
  existing_metadata = []
205
+
 
206
  if json_path.exists():
207
  try:
208
  with open(json_path, 'r', encoding='utf-8') as f:
 
237
  'message': f'Files saved successfully! {len(saved_files)} original files saved to data folder',
238
  'saved_files': saved_files,
239
  'processed_count': len(saved_files),
240
+ 'chunks_preview': chunks_preview_data,
241
  'data_folder': str(data_dir),
242
  'metadata_json': str(json_path),
243
  'approach': 'original_files_only'
 
349
  config_file = None
350
  model_file_path = None
351
 
352
+ # Priority: unwrapped_model_path > model_path > base model.
353
  if unwrapped_model_path:
354
  model_file_path = Path(unwrapped_model_path)
355
  if not model_file_path.exists():
 
364
  f"model_path:{model_name}", str(model_file_path))
365
  logger.debug(f"Using model path: {model_file_path}")
366
 
367
+ # Small and full models use different configs; pick by file size when the name is ambiguous.
368
  if model_file_path:
369
  file_size_gb = model_file_path.stat().st_size / (1024**3)
370
  config_file = "model_config_small.json" if file_size_gb < 2.0 else "model_config.json"
 
385
  logger.info(f"Starting generation with config: {config_file}")
386
  try:
387
  if determined_model_path and determined_model_path.exists():
 
388
  output_path = generator.generate_audio(
389
  prompt,
390
  unwrapped_model_path=unwrapped_model_path if unwrapped_model_path else None,
 
393
  duration=duration
394
  )
395
  elif model_name in ['stable-audio-open-small', 'stable-audio-open-1.0']:
 
396
  model_file_mapping = {
397
  'stable-audio-open-small': 'stable-audio-open-small-model.safetensors',
398
  'stable-audio-open-1.0': 'stable-audio-open-model.safetensors'
 
529
  has_checkpoint = len(checkpoint_files) > 0
530
  has_config = len(config_files) > 0
531
 
 
532
  checkpoints = []
533
  for ckpt_file in checkpoint_files:
 
534
  import re
535
  name = ckpt_file.stem
536
  epoch_match = re.search(r'epoch=(\d+)', name)
 
538
 
539
  checkpoint_info = {
540
  'name': name,
 
541
  'path': str(ckpt_file.relative_to(config.project_root)),
542
  'size_mb': round(ckpt_file.stat().st_size / (1024 * 1024), 1),
543
  'created': ckpt_file.stat().st_mtime
 
550
 
551
  checkpoints.append(checkpoint_info)
552
 
 
553
  checkpoints.sort(key=lambda x: x['created'], reverse=True)
554
 
 
555
  latest_checkpoint = max(checkpoint_files, key=lambda x: x.stat(
556
  ).st_mtime) if checkpoint_files else None
557
  latest_config = max(
558
  config_files, key=lambda x: x.stat().st_mtime) if config_files else None
559
 
 
560
  unwrapped_dir = model_dir / "unwrapped"
561
  unwrapped_models = []
562
  if unwrapped_dir.exists():
563
  for unwrapped_file in unwrapped_dir.glob("*.safetensors"):
564
  unwrapped_models.append({
565
  'name': unwrapped_file.stem,
 
566
  'path': str(unwrapped_file.relative_to(config.project_root)),
567
  'size_mb': round(unwrapped_file.stat().st_size / (1024 * 1024), 1),
568
  'created': unwrapped_file.stat().st_mtime
569
  })
570
 
 
571
  unwrapped_models.sort(
572
  key=lambda x: x['created'], reverse=True)
573
 
574
+ # Fine-tuned models reuse the base model's config for unwrapping.
575
+ base_config_path = "models/config/model_config_small.json"
576
 
577
  models.append({
578
  'name': model_dir.name,
 
579
  'path': str(model_dir.relative_to(config.project_root)),
580
  'has_checkpoint': has_checkpoint,
581
  'has_config': has_config,
 
582
  'ckpt_path': str(latest_checkpoint.relative_to(config.project_root)) if latest_checkpoint else None,
583
+ 'config_path': base_config_path,
584
+ 'checkpoints': checkpoints,
585
  'unwrapped_models': unwrapped_models,
586
  'created': model_dir.stat().st_mtime if model_dir.exists() else None
587
  })
 
593
 
594
  @app.route('/api/models/available', methods=['GET'])
595
  def get_available_models():
 
596
  try:
597
  models = model_manager.get_available_models()
598
  return jsonify({'models': models})
 
602
 
603
  @app.route('/api/models/<model_id>/info', methods=['GET'])
604
  def get_model_info(model_id):
 
605
  try:
606
  model_info = model_manager.get_model_info(model_id)
607
  if not model_info:
 
613
 
614
  @app.route('/api/models/<model_id>/accept-terms', methods=['POST'])
615
  def accept_model_terms(model_id):
 
616
  try:
617
  success = model_manager.accept_terms(model_id)
618
  if success:
 
625
 
626
  @app.route('/api/models/<model_id>/download', methods=['POST'])
627
  def download_model(model_id):
 
628
  try:
 
629
  if not model_manager.is_terms_accepted(model_id):
630
  return jsonify({'error': 'Terms not accepted for this model'}), 400
631
 
 
632
  success = model_manager.download_model(model_id)
633
  if success:
634
  return jsonify({
 
643
 
644
  @app.route('/api/hf-login', methods=['POST'])
645
  def hf_login():
 
646
  try:
647
  data = request.json
648
  token = data.get('token')
 
662
 
663
  @app.route('/api/base-models/status', methods=['GET'])
664
  def get_base_models_status():
 
665
  try:
666
  import os
667
  from pathlib import Path
668
+
669
  base_models = {
670
  'stable-audio-open-1.0': {
671
  'name': 'Stable Audio Open 1.0',
672
+ 'path': 'models/pretrained',
673
+ 'file': 'stable-audio-open-model.safetensors',
674
  'downloaded': False
675
  },
676
  'stable-audio-open-small': {
677
+ 'name': 'Stable Audio Open Small',
678
+ 'path': 'models/pretrained',
679
+ 'file': 'stable-audio-open-small-model.safetensors',
680
  'downloaded': False
681
  }
682
  }
683
+
 
684
  for model_id, info in base_models.items():
685
  model_dir = Path(info['path'])
686
  model_file = model_dir / info['file']
687
+
 
688
  if model_file.exists() and model_file.is_file():
689
  info['downloaded'] = True
690
  else:
691
+ # Legacy layout: model stored in a subdirectory.
692
  old_path = model_dir / model_id
693
  if old_path.exists() and old_path.is_dir():
694
  has_files = any([
 
707
 
708
  @app.route('/api/models/<model_id>/delete', methods=['DELETE'])
709
  def delete_model(model_id):
 
710
  try:
711
  success = model_manager.delete_model(model_id)
712
  if success:
 
719
 
720
  @app.route('/api/models/storage', methods=['GET'])
721
  def get_model_storage():
 
722
  try:
723
  storage_info = model_manager.get_storage_info()
724
  return jsonify(storage_info)
 
728
 
729
  @app.route('/api/start-fresh', methods=['POST'])
730
  def start_fresh():
 
731
  try:
732
  config = get_config()
733
  data_dir = config.get_path("data")
734
  config_dir = config.get_path("models_config")
735
 
 
736
  data_files_deleted = 0
737
  if data_dir.exists():
738
  for file_path in data_dir.glob("*"):
739
+ if file_path.is_file() and not file_path.name.endswith('.py'):
740
  file_path.unlink()
741
  data_files_deleted += 1
742
 
 
743
  config_files_deleted = 0
744
  if config_dir.exists():
745
  for file_path in config_dir.glob("custom_metadata.py"):
 
747
  file_path.unlink()
748
  config_files_deleted += 1
749
 
 
750
  data_dir.mkdir(exist_ok=True, parents=True)
751
 
752
  return jsonify({
 
761
 
762
  @app.route('/api/unwrap-model', methods=['POST'])
763
  def unwrap_model():
 
764
  try:
765
  data = request.json
766
  model_config = data.get('model_config')
 
770
  if not model_config or not ckpt_path:
771
  return jsonify({'error': 'model_config and ckpt_path are required'}), 400
772
 
 
773
  import subprocess
774
  from pathlib import Path
775
 
 
776
  config = get_config()
777
  repo_root = config.project_root
778
 
 
779
  model_config_path = repo_root / \
780
  model_config if not Path(
781
  model_config).is_absolute() else Path(model_config)
782
  ckpt_path_resolved = repo_root / \
783
  ckpt_path if not Path(ckpt_path).is_absolute() else Path(ckpt_path)
784
 
 
785
  if not model_config_path.exists():
786
  return jsonify({'error': f'Model config not found: {model_config_path}'}), 400
787
  if not ckpt_path_resolved.exists():
788
  return jsonify({'error': f'Checkpoint not found: {ckpt_path_resolved}'}), 400
789
 
 
790
  model_dir = ckpt_path_resolved.parent
791
  unwrapped_dir = model_dir / "unwrapped"
792
  unwrapped_dir.mkdir(exist_ok=True)
793
 
794
  cmd = [
 
795
  sys.executable, 'unwrap_model.py',
796
  '--model-config', str(model_config_path),
797
  '--ckpt-path', str(ckpt_path_resolved),
 
799
  '--use-safetensors'
800
  ]
801
 
802
+ # unwrap_model.py writes next to its CWD, so run from stable-audio-tools/.
803
  stable_audio_dir = repo_root / "stable-audio-tools"
804
 
805
  proc = subprocess.run(cmd, cwd=stable_audio_dir,
806
  capture_output=True, text=True)
807
 
808
  if proc.returncode == 0:
 
 
809
 
 
810
  import glob
811
  pattern = str(stable_audio_dir / f"{name}*.safetensors")
812
  created_files = glob.glob(pattern)
 
817
  target_path = unwrapped_dir / created_path.name
818
 
819
  try:
 
820
  created_path.rename(target_path)
821
  moved_files.append(str(target_path))
822
  print(f"Moved {created_path.name} to {target_path}")
823
  except Exception as e:
824
  print(f"Error moving {created_path}: {e}")
825
 
 
826
  unwrapped_files = list(unwrapped_dir.glob("*.safetensors"))
827
 
828
  return jsonify({
 
840
 
841
  @app.route('/api/delete-checkpoint', methods=['POST'])
842
  def delete_checkpoint():
 
843
  try:
844
  data = request.json
845
  checkpoint_path = data.get('checkpoint_path')
 
847
  if not checkpoint_path:
848
  return jsonify({'error': 'checkpoint_path is required'}), 400
849
 
 
850
  config = get_config()
851
  repo_root = config.project_root
852
 
 
853
  ckpt_path_resolved = repo_root / \
854
  checkpoint_path if not Path(
855
  checkpoint_path).is_absolute() else Path(checkpoint_path)
 
857
  if not ckpt_path_resolved.exists():
858
  return jsonify({'error': f'Checkpoint file not found: {ckpt_path_resolved}'}), 404
859
 
860
+ # Restrict deletion to .ckpt to avoid accidental loss of unwrapped models.
861
  if not ckpt_path_resolved.suffix == '.ckpt':
862
  return jsonify({'error': f'Only .ckpt files can be deleted: {ckpt_path_resolved}'}), 400
863
 
 
877
 
878
  @app.route('/api/delete-wrapped-checkpoint', methods=['POST'])
879
  def delete_wrapped_checkpoint():
 
880
  try:
881
  data = request.json
882
  model_name = data.get('model_name')
 
884
  if not model_name:
885
  return jsonify({'error': 'model_name is required'}), 400
886
 
 
887
  config = get_config()
888
  models_dir = config.get_path("models_fine_tuned")
889
  model_dir = models_dir / model_name
 
891
  if not model_dir.exists():
892
  return jsonify({'error': f'Model directory not found: {model_dir}'}), 404
893
 
 
894
  deleted_files = []
895
  for ckpt_file in model_dir.glob("*.ckpt"):
896
  try:
 
913
 
914
  @app.route('/api/free-gpu-memory', methods=['POST'])
915
  def free_gpu_memory():
 
916
  try:
917
  import subprocess
918
  import torch
 
921
 
922
  print(" FREEING GPU MEMORY...")
923
 
 
924
  if torch.cuda.is_available():
925
  torch.cuda.empty_cache()
926
  print(" Cleared PyTorch CUDA cache")
927
 
 
928
  if hasattr(torch, 'mps') and torch.backends.mps.is_available():
929
  torch.mps.empty_cache()
930
  print(" Cleared MPS cache")
931
 
 
932
  current_pid = os.getpid()
933
  print(f" Current process PID: {current_pid}")
934
 
 
935
  try:
 
936
  result = subprocess.run(['nvidia-smi', '--query-compute-apps=pid,used_memory,process_name', '--format=csv,noheader,nounits'],
937
  capture_output=True, text=True, timeout=10)
938
 
 
947
  pid_int = int(pid)
948
  mem_gb = float(mem_mb) / 1024
949
 
 
950
  if pid_int == current_pid:
951
  print(
952
  f" Skipping current process PID: {pid_int}")
953
  continue
954
 
 
955
  if 'python' in process_name.lower() and mem_gb > 1.0:
956
  print(
957
  f" Found Python process PID: {pid_int} using {mem_gb:.1f}GB")
958
  print(f" Process: {process_name}")
959
 
 
960
  try:
 
961
  subprocess.run(
962
  ['kill', '-TERM', str(pid_int)], check=False, timeout=5)
963
  print(
964
  f" Sent SIGTERM to PID: {pid_int}")
965
 
 
966
  time.sleep(2)
967
 
 
968
  try:
 
969
  os.kill(pid_int, 0)
970
  print(
971
  f" Process {pid_int} still running, sending SIGKILL")
 
993
  except Exception as e:
994
  print(f" Could not check CUDA processes: {e}")
995
 
 
996
  time.sleep(3)
997
 
 
998
  if torch.cuda.is_available():
999
  torch.cuda.empty_cache()
1000
  print(" Cleared PyTorch CUDA cache again")
1001
 
 
1002
  memory_info = {}
1003
  if torch.cuda.is_available():
 
1004
  total_memory = torch.cuda.get_device_properties(
1005
  0).total_memory / (1024**3)
1006
  torch.cuda.synchronize()
 
1008
  cached_memory = torch.cuda.memory_reserved(0) / (1024**3)
1009
  free_memory = total_memory - allocated_memory
1010
 
 
1011
  try:
1012
  result = subprocess.run(['nvidia-smi', '--query-gpu=memory.used,memory.total', '--format=csv,noheader,nounits'],
1013
  capture_output=True, text=True, timeout=5)
 
1026
  nvidia_total_gb = total_memory
1027
  nvidia_free_gb = total_memory
1028
 
1029
+ # PyTorch sometimes reports 0 for externally-allocated memory; fall back to nvidia-smi.
1030
  if allocated_memory > 0:
1031
  final_allocated = allocated_memory
1032
  final_free = free_memory
 
1065
 
1066
  @app.route('/api/toggle-debug', methods=['POST'])
1067
  def toggle_debug():
 
1068
  global DEBUG_MODE
1069
  try:
1070
  data = request.json
 
1084
 
1085
  @app.route('/api/debug-status', methods=['GET'])
1086
  def get_debug_status():
 
1087
  return jsonify({
1088
  'debug_mode': DEBUG_MODE,
1089
  'message': f"Debug mode is {'enabled' if DEBUG_MODE else 'disabled'}"
1090
  })
1091
 
1092
 
 
1093
  _api_call_stats = {
1094
  'gpu_memory_status': 0,
1095
  'status': 0,
 
1099
 
1100
 
1101
  def _log_api_call(endpoint):
 
1102
  global _api_call_stats
1103
  _api_call_stats[endpoint] = _api_call_stats.get(endpoint, 0) + 1
1104
 
 
1105
  if time.time() - _api_call_stats['last_reset'] > 3600:
1106
  _api_call_stats = {endpoint: 1, 'last_reset': time.time()}
1107
 
1108
 
1109
  @app.route('/api/debug-stats', methods=['GET'])
1110
  def get_debug_stats():
 
1111
  return jsonify({
1112
  'api_call_stats': _api_call_stats,
1113
  'uptime_hours': (time.time() - _api_call_stats['last_reset']) / 3600,
 
1119
  })
1120
 
1121
 
 
1122
  _gpu_memory_cache = {}
1123
  _gpu_memory_cache_time = 0
1124
+ _gpu_memory_cache_duration = 2.0
1125
 
 
1126
  _last_memory_warning_time = 0
1127
+ _memory_warning_interval = 30
1128
 
1129
 
1130
  @app.route('/api/open-output-folder', methods=['POST'])
1131
  def open_output_folder():
 
1132
  try:
1133
  import subprocess
1134
  import platform
 
1151
 
1152
  @app.route('/api/open-documentation', methods=['POST'])
1153
  def open_documentation():
 
1154
  try:
1155
  import webbrowser
1156
 
 
1181
  logger.error(f"Error opening documentation: {e}")
1182
  return jsonify({"success": False, "error": str(e)}), 500
1183
 
 
1184
  _welcome_page_closed = False
1185
 
1186
  @app.route('/api/welcome-page-closed', methods=['POST'])
1187
  def welcome_page_closed():
 
1188
  global _welcome_page_closed
1189
  try:
1190
  _welcome_page_closed = True
 
1196
 
1197
  @app.route('/api/welcome-page-status', methods=['GET'])
1198
  def get_welcome_page_status():
 
1199
  global _welcome_page_closed
1200
  return jsonify({"closed": _welcome_page_closed})
1201
 
1202
  @app.route('/api/license-info', methods=['GET'])
1203
  def get_license_info():
 
1204
  try:
1205
  project_root = Path(__file__).parent.parent.parent
1206
+
 
1207
  license_file = project_root / "LICENSE"
1208
  license_text = ""
1209
  if license_file.exists():
1210
  with open(license_file, 'r', encoding='utf-8') as f:
 
1211
  lines = f.readlines()[:50]
1212
  license_text = ''.join(lines)
1213
+
 
1214
  notice_file = project_root / "NOTICE.md"
1215
  notice_text = ""
1216
  if notice_file.exists():
 
1234
 
1235
  @app.route('/api/models-status', methods=['GET'])
1236
  def get_models_status():
 
1237
  try:
1238
  required_models = ['stable-audio-open-small', 'stable-audio-open-1.0']
1239
  downloaded_models = [
 
1278
 
1279
  @app.route('/api/gpu-memory-status', methods=['GET'])
1280
  def get_gpu_memory_status():
 
1281
  _log_api_call('gpu_memory_status')
1282
  global _gpu_memory_cache, _gpu_memory_cache_time
1283
 
 
1284
  current_time = time.time()
1285
  if current_time - _gpu_memory_cache_time < _gpu_memory_cache_duration:
1286
  return jsonify({'memory_info': _gpu_memory_cache})
 
1292
 
1293
  memory_info = {}
1294
  if torch.cuda.is_available():
 
1295
  total_memory = torch.cuda.get_device_properties(
1296
  0).total_memory / (1024**3)
1297
 
 
1298
  torch.cuda.synchronize()
1299
  allocated_memory = torch.cuda.memory_allocated(0) / (1024**3)
1300
  cached_memory = torch.cuda.memory_reserved(0) / (1024**3)
1301
  free_memory = total_memory - allocated_memory
1302
 
 
1303
  nvidia_used_gb = 0
1304
  nvidia_total_gb = total_memory
1305
  nvidia_free_gb = total_memory
1306
 
1307
+ # PyTorch reports 0 when memory is held by other processes; ask nvidia-smi instead.
1308
  if allocated_memory == 0:
1309
  try:
1310
  result = subprocess.run(['nvidia-smi', '--query-gpu=memory.used,memory.total', '--format=csv,noheader,nounits'],
1311
+ capture_output=True, text=True, timeout=1)
1312
  if result.stdout.strip():
1313
  used_mb, total_mb = result.stdout.strip().split(', ')
1314
  nvidia_used_gb = float(used_mb) / 1024
1315
  nvidia_total_gb = float(total_mb) / 1024
1316
  nvidia_free_gb = nvidia_total_gb - nvidia_used_gb
1317
  except Exception as e:
 
1318
  if "Could not get nvidia-smi info" not in str(e):
1319
  print(f"GPU Memory Error: {e}")
1320
 
 
1321
  cuda_capability = torch.cuda.get_device_capability(0)
1322
  device_name = torch.cuda.get_device_name(0)
1323
 
 
 
 
1324
  if allocated_memory > 0:
1325
  final_allocated = allocated_memory
1326
  final_cached = cached_memory
 
1328
  memory_source = "PyTorch"
1329
  else:
1330
  final_allocated = nvidia_used_gb
1331
+ final_cached = cached_memory
1332
  final_free = nvidia_free_gb
1333
  memory_source = "nvidia-smi"
1334
 
 
1345
  'nvidia_used': nvidia_used_gb
1346
  }
1347
 
 
1348
  global _last_memory_warning_time
1349
  if (current_time - _last_memory_warning_time) > _memory_warning_interval:
1350
+ if final_allocated > 10.0:
1351
  print(
1352
  f" High GPU Memory Usage: {final_allocated:.2f}GB allocated, {final_free:.2f}GB free")
1353
  _last_memory_warning_time = current_time
1354
+ elif final_free < 1.0:
1355
  print(
1356
  f" Low GPU Memory: {final_free:.2f}GB free, {final_allocated:.2f}GB allocated")
1357
  _last_memory_warning_time = current_time
1358
  else:
 
1359
  memory_info['cpu'] = {
1360
  'total': psutil.virtual_memory().total / (1024**3),
1361
  'available': psutil.virtual_memory().available / (1024**3),
 
1364
  'type': 'cpu'
1365
  }
1366
 
 
1367
  _gpu_memory_cache = memory_info
1368
  _gpu_memory_cache_time = current_time
1369
 
 
1373
  return jsonify({'error': str(e)}), 500
1374
 
1375
 
 
 
 
1376
  _annotate_job_lock = threading.Lock()
1377
  _annotate_job = {
1378
  'state': 'idle', # idle | running | done | error
 
1400
  return clap_checkpoint_path(get_config().get_path('models_pretrained'))
1401
 
1402
 
1403
+ @app.route('/api/environment', methods=['GET'])
1404
+ def environment():
1405
+ return jsonify({
1406
+ 'docker': os.environ.get('FRAGMENTA_DOCKER', '0') == '1',
1407
+ })
1408
+
1409
+
1410
+ @app.route('/api/upload-folder', methods=['POST'])
1411
+ def upload_folder():
1412
+ # Browser-native folder upload path for containerised deployments
1413
+ # (e.g. HF Space) where no display server is available for a native dialog.
1414
+ audio_exts = {'.wav', '.mp3', '.flac', '.m4a', '.ogg', '.aac'}
1415
+
1416
+ files = request.files.getlist('files')
1417
+ rel_paths = request.form.getlist('rel_paths')
1418
+
1419
+ if not files:
1420
+ return jsonify({'error': 'No files uploaded.'}), 400
1421
+ if len(rel_paths) != len(files):
1422
+ return jsonify({'error': 'rel_paths count does not match files count.'}), 400
1423
+
1424
+ first_rel = (rel_paths[0] or '').replace('\\', '/').lstrip('/')
1425
+ folder_name = first_rel.split('/', 1)[0] if '/' in first_rel else 'folder'
1426
+ safe_folder = ''.join(c for c in folder_name if c.isalnum() or c in '-_') or 'folder'
1427
+
1428
+ staging_root = get_config().get_path('data') / 'uploads'
1429
+ staging_root.mkdir(parents=True, exist_ok=True)
1430
+ target_dir = staging_root / f"{int(time.time())}-{safe_folder}"
1431
+ target_dir.mkdir(parents=True, exist_ok=True)
1432
+
1433
+ saved = 0
1434
+ for file_obj, rel in zip(files, rel_paths):
1435
+ rel_norm = (rel or file_obj.filename or '').replace('\\', '/').lstrip('/')
1436
+ if not rel_norm or '..' in rel_norm.split('/'):
1437
+ continue
1438
+ if Path(rel_norm).suffix.lower() not in audio_exts:
1439
+ continue
1440
+
1441
+ dest = (target_dir / rel_norm).resolve()
1442
+ try:
1443
+ dest.relative_to(target_dir.resolve())
1444
+ except ValueError:
1445
+ continue
1446
+
1447
+ dest.parent.mkdir(parents=True, exist_ok=True)
1448
+ file_obj.save(dest)
1449
+ saved += 1
1450
+
1451
+ if saved == 0:
1452
+ import shutil
1453
+ shutil.rmtree(target_dir, ignore_errors=True)
1454
+ return jsonify({'error': 'No audio files found in the selected folder.'}), 400
1455
+
1456
+ return jsonify({'path': str(target_dir), 'file_count': saved})
1457
+
1458
+
1459
  @app.route('/api/pick-folder', methods=['POST'])
1460
  def pick_folder():
 
1461
  import subprocess
1462
  import shutil as _shutil
1463
 
 
1688
 
1689
  @app.route('/shutdown', methods=['POST'])
1690
  def shutdown():
 
1691
  try:
1692
  print(" Shutting down Flask server...")
 
1693
  func = request.environ.get('werkzeug.server.shutdown')
1694
  if func is None:
1695
  raise RuntimeError('Not running with the Werkzeug Server')
 
1700
 
1701
 
1702
  if __name__ == '__main__':
 
1703
  host = os.environ.get('FLASK_HOST', '0.0.0.0')
1704
  port = int(os.environ.get('FLASK_PORT', '5001'))
1705
  app.run(debug=True, host=host, port=port)
app/backend/data/simple_audio_processor.py CHANGED
@@ -9,7 +9,6 @@ logger = logging.getLogger(__name__)
9
  def fast_scandir(dir_path, ext_list):
10
  import os
11
  subfolders, files = [], []
12
- # add starting period to extensions if needed
13
  ext_list = ['.'+x if x[0] != '.' else x for x in ext_list]
14
 
15
  try:
@@ -39,8 +38,7 @@ class SimpleAudioProcessor:
39
 
40
  def __init__(self, model_config_path: Optional[Path] = None):
41
  self.audio_extensions = (".wav", ".mp3", ".flac", ".m4a")
42
-
43
- # Load model config for info only
44
  if model_config_path and model_config_path.exists():
45
  with open(model_config_path, 'r') as f:
46
  model_config = json.load(f)
@@ -48,7 +46,6 @@ class SimpleAudioProcessor:
48
  self.sample_rate = model_config.get("sample_rate", 44100)
49
  self.audio_channels = model_config.get("audio_channels", 2)
50
  else:
51
- # Defaults
52
  self.sample_size = 2097152
53
  self.sample_rate = 44100
54
  self.audio_channels = 2
@@ -72,7 +69,6 @@ class SimpleAudioProcessor:
72
  output_dir: Path,
73
  prompts_file: Optional[Path] = None
74
  ) -> Dict[str, Any]:
75
- # Find audio files
76
  audio_files = []
77
  for ext in self.audio_extensions:
78
  _, files = fast_scandir(str(input_dir), [ext[1:]])
@@ -83,37 +79,34 @@ class SimpleAudioProcessor:
83
 
84
  logger.info(f"Found {len(audio_files)} audio files")
85
 
86
- # Create output directory
87
  output_dir.mkdir(exist_ok=True, parents=True)
88
-
89
- # Copy files to output directory (only if different directories)
90
  if input_dir != output_dir:
91
  import shutil
92
  for audio_file in audio_files:
93
  src_path = Path(audio_file)
94
  dst_path = output_dir / src_path.name
95
-
96
  if not dst_path.exists() or dst_path.stat().st_size != src_path.stat().st_size:
97
  shutil.copy2(src_path, dst_path)
98
  logger.info(f"Copied {src_path.name}")
99
  else:
100
  logger.info("Input and output directories are the same - no copying needed")
101
 
102
- # Create simple dataset config
103
  dataset_config = {
104
  "dataset_type": "audio_dir",
105
  "datasets": [
106
  {
107
- "id": "custom_dataset",
108
  "path": str(output_dir),
109
  "custom_metadata_module": "custom_metadata"
110
  }
111
  ],
112
- "random_crop": True, # CRITICAL - enables random cropping during training
 
113
  "drop_last": True
114
  }
115
 
116
- # Save prompts if provided
117
  if prompts_file and prompts_file.exists():
118
  prompts = self.load_prompts(prompts_file)
119
  if prompts:
 
9
  def fast_scandir(dir_path, ext_list):
10
  import os
11
  subfolders, files = [], []
 
12
  ext_list = ['.'+x if x[0] != '.' else x for x in ext_list]
13
 
14
  try:
 
38
 
39
  def __init__(self, model_config_path: Optional[Path] = None):
40
  self.audio_extensions = (".wav", ".mp3", ".flac", ".m4a")
41
+
 
42
  if model_config_path and model_config_path.exists():
43
  with open(model_config_path, 'r') as f:
44
  model_config = json.load(f)
 
46
  self.sample_rate = model_config.get("sample_rate", 44100)
47
  self.audio_channels = model_config.get("audio_channels", 2)
48
  else:
 
49
  self.sample_size = 2097152
50
  self.sample_rate = 44100
51
  self.audio_channels = 2
 
69
  output_dir: Path,
70
  prompts_file: Optional[Path] = None
71
  ) -> Dict[str, Any]:
 
72
  audio_files = []
73
  for ext in self.audio_extensions:
74
  _, files = fast_scandir(str(input_dir), [ext[1:]])
 
79
 
80
  logger.info(f"Found {len(audio_files)} audio files")
81
 
 
82
  output_dir.mkdir(exist_ok=True, parents=True)
83
+
 
84
  if input_dir != output_dir:
85
  import shutil
86
  for audio_file in audio_files:
87
  src_path = Path(audio_file)
88
  dst_path = output_dir / src_path.name
89
+
90
  if not dst_path.exists() or dst_path.stat().st_size != src_path.stat().st_size:
91
  shutil.copy2(src_path, dst_path)
92
  logger.info(f"Copied {src_path.name}")
93
  else:
94
  logger.info("Input and output directories are the same - no copying needed")
95
 
 
96
  dataset_config = {
97
  "dataset_type": "audio_dir",
98
  "datasets": [
99
  {
100
+ "id": "custom_dataset",
101
  "path": str(output_dir),
102
  "custom_metadata_module": "custom_metadata"
103
  }
104
  ],
105
+ # random_crop is required: without it, training always sees file start.
106
+ "random_crop": True,
107
  "drop_last": True
108
  }
109
 
 
110
  if prompts_file and prompts_file.exists():
111
  prompts = self.load_prompts(prompts_file)
112
  if prompts:
app/core/config.py CHANGED
@@ -8,18 +8,15 @@ class ProjectConfig:
8
 
9
  def __init__(self, project_root: Optional[Path] = None) -> None:
10
  if getattr(sys, 'frozen', False):
11
- # Running in PyInstaller bundle
12
  self.frozen = True
13
- # sys._MEIPASS is where PyInstaller unpacks the bundle
14
  self.project_root = Path(sys._MEIPASS)
15
-
16
- # For writable data, use a user directory
17
  if sys.platform == "win32":
18
  self.user_data_dir = Path(os.environ["APPDATA"]) / "FragmentaDesktop"
19
  elif sys.platform == "darwin":
20
  self.user_data_dir = Path.home() / "Library" / "Application Support" / "FragmentaDesktop"
21
  else:
22
- # Linux/Unix
23
  self.user_data_dir = Path.home() / ".local" / "share" / "FragmentaDesktop"
24
 
25
  self.user_data_dir.mkdir(parents=True, exist_ok=True)
@@ -44,8 +41,9 @@ class ProjectConfig:
44
  self.project_root: Path = Path(project_root).resolve()
45
  self.user_data_dir = self.project_root
46
 
 
 
47
  self.paths: Dict[str, Path] = {
48
- # Writable paths - go to user_data_dir in frozen mode
49
  "models": self.user_data_dir / "models",
50
  "models_config": self.user_data_dir / "models" / "config",
51
  "models_pretrained": self.user_data_dir / "models" / "pretrained",
@@ -53,8 +51,7 @@ class ProjectConfig:
53
  "data": self.user_data_dir / "data",
54
  "logs": self.user_data_dir / "logs",
55
  "output": self.user_data_dir / "output",
56
-
57
- # Read-only attributes/codebase - stay in project_root
58
  "application": self.project_root,
59
  "backend": self.project_root / "app" / "backend",
60
  "frontend": self.project_root / "app" / "frontend",
 
8
 
9
  def __init__(self, project_root: Optional[Path] = None) -> None:
10
  if getattr(sys, 'frozen', False):
 
11
  self.frozen = True
12
+ # PyInstaller unpacks the bundle to sys._MEIPASS; writable data lives elsewhere.
13
  self.project_root = Path(sys._MEIPASS)
14
+
 
15
  if sys.platform == "win32":
16
  self.user_data_dir = Path(os.environ["APPDATA"]) / "FragmentaDesktop"
17
  elif sys.platform == "darwin":
18
  self.user_data_dir = Path.home() / "Library" / "Application Support" / "FragmentaDesktop"
19
  else:
 
20
  self.user_data_dir = Path.home() / ".local" / "share" / "FragmentaDesktop"
21
 
22
  self.user_data_dir.mkdir(parents=True, exist_ok=True)
 
41
  self.project_root: Path = Path(project_root).resolve()
42
  self.user_data_dir = self.project_root
43
 
44
+ # Writable paths live under user_data_dir (diverges from project_root in frozen mode);
45
+ # read-only code/assets stay under project_root.
46
  self.paths: Dict[str, Path] = {
 
47
  "models": self.user_data_dir / "models",
48
  "models_config": self.user_data_dir / "models" / "config",
49
  "models_pretrained": self.user_data_dir / "models" / "pretrained",
 
51
  "data": self.user_data_dir / "data",
52
  "logs": self.user_data_dir / "logs",
53
  "output": self.user_data_dir / "output",
54
+
 
55
  "application": self.project_root,
56
  "backend": self.project_root / "app" / "backend",
57
  "frontend": self.project_root / "app" / "frontend",
app/core/generation/audio_generator.py CHANGED
@@ -155,23 +155,6 @@ class AudioGenerator:
155
  seed: int = -1,
156
  output_path: Optional[Path] = None
157
  ) -> Path:
158
- """
159
- Generate audio from a text prompt
160
-
161
- Args:
162
- prompt: Text description of the audio to generate
163
- model_path: Path to fine-tuned model directory
164
- unwrapped_model_path: Path to unwrapped .safetensors file
165
- config_file: Model config file to use (small or large)
166
- duration: Duration in seconds
167
- cfg_scale: Classifier-free guidance scale
168
- steps: Number of diffusion steps
169
- seed: Random seed (-1 for random)
170
- output_path: Optional path to save the generated audio
171
-
172
- Returns:
173
- Path to the generated audio file
174
- """
175
  print(f"\nAUDIO GENERATOR: generate_audio called")
176
  print(f" - Prompt: '{prompt}'")
177
  print(f" - Duration: {duration}s")
 
155
  seed: int = -1,
156
  output_path: Optional[Path] = None
157
  ) -> Path:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  print(f"\nAUDIO GENERATOR: generate_audio called")
159
  print(f" - Prompt: '{prompt}'")
160
  print(f" - Duration: {duration}s")
app/core/model_manager.py CHANGED
@@ -223,31 +223,28 @@ class ModelManager:
223
  import shutil
224
  from tqdm import tqdm
225
  import sys
226
-
227
- # Redirect tqdm to capture progress
228
  class TqdmToCallback:
229
  def __init__(self, callback, file_index, total_files):
230
  self.callback = callback
231
  self.file_index = file_index
232
  self.total_files = total_files
233
  self.last_percent = 0
234
-
235
  def __call__(self, t):
236
- """Returns a callback function for tqdm"""
237
  def inner(bytes_amount=1):
238
  if t.total:
239
- # Calculate progress: 20-90% range for all files
240
  file_progress = (t.n / t.total)
241
  overall_progress = (self.file_index + file_progress) / self.total_files
242
  percent = 20 + int(overall_progress * 70)
243
-
244
  if percent != self.last_percent:
245
  self.last_percent = percent
246
  downloaded_mb = t.n / (1024 * 1024)
247
  total_mb = t.total / (1024 * 1024)
248
  if self.callback:
249
  self.callback(
250
- percent,
251
  f"Downloading: {downloaded_mb:.1f}MB / {total_mb:.1f}MB"
252
  )
253
  return inner
@@ -273,15 +270,14 @@ class ModelManager:
273
  else:
274
  final_filename = f"{model_id}-{file_pattern}"
275
 
276
- # Use custom tqdm callback to intercept progress
277
  tqdm_callback = TqdmToCallback(progress_callback, i, total_files)
278
-
279
- # Monkey-patch tqdm for this download
 
280
  original_tqdm_init = tqdm.__init__
281
-
282
  def patched_tqdm_init(self, *args, **kwargs):
283
  original_tqdm_init(self, *args, **kwargs)
284
- # Hook into tqdm updates
285
  original_update = self.update
286
  def new_update(n=1):
287
  result = original_update(n)
@@ -307,7 +303,6 @@ class ModelManager:
307
  resume_download=True
308
  )
309
  finally:
310
- # Restore original tqdm
311
  tqdm.__init__ = original_tqdm_init
312
 
313
  downloaded_path = Path(downloaded_file)
 
223
  import shutil
224
  from tqdm import tqdm
225
  import sys
226
+
 
227
  class TqdmToCallback:
228
  def __init__(self, callback, file_index, total_files):
229
  self.callback = callback
230
  self.file_index = file_index
231
  self.total_files = total_files
232
  self.last_percent = 0
233
+
234
  def __call__(self, t):
 
235
  def inner(bytes_amount=1):
236
  if t.total:
 
237
  file_progress = (t.n / t.total)
238
  overall_progress = (self.file_index + file_progress) / self.total_files
239
  percent = 20 + int(overall_progress * 70)
240
+
241
  if percent != self.last_percent:
242
  self.last_percent = percent
243
  downloaded_mb = t.n / (1024 * 1024)
244
  total_mb = t.total / (1024 * 1024)
245
  if self.callback:
246
  self.callback(
247
+ percent,
248
  f"Downloading: {downloaded_mb:.1f}MB / {total_mb:.1f}MB"
249
  )
250
  return inner
 
270
  else:
271
  final_filename = f"{model_id}-{file_pattern}"
272
 
 
273
  tqdm_callback = TqdmToCallback(progress_callback, i, total_files)
274
+
275
+ # hf_hub_download drives its own tqdm — monkey-patch its init/update so we
276
+ # forward byte progress to progress_callback without a second progress bar.
277
  original_tqdm_init = tqdm.__init__
278
+
279
  def patched_tqdm_init(self, *args, **kwargs):
280
  original_tqdm_init(self, *args, **kwargs)
 
281
  original_update = self.update
282
  def new_update(n=1):
283
  result = original_update(n)
 
303
  resume_download=True
304
  )
305
  finally:
 
306
  tqdm.__init__ = original_tqdm_init
307
 
308
  downloaded_path = Path(downloaded_file)
app/frontend/build/assets/index-RtS7dlIj.js ADDED
The diff for this file is too large to render. See raw diff
 
app/frontend/build/index.html CHANGED
@@ -26,7 +26,7 @@
26
  </style>
27
 
28
  <title>Fragmenta Desktop</title>
29
- <script type="module" crossorigin src="/assets/index-D-qgc0vE.js"></script>
30
  </head>
31
  <body>
32
  <noscript>You need to enable JavaScript to run this app.</noscript>
 
26
  </style>
27
 
28
  <title>Fragmenta Desktop</title>
29
+ <script type="module" crossorigin src="/assets/index-RtS7dlIj.js"></script>
30
  </head>
31
  <body>
32
  <noscript>You need to enable JavaScript to run this app.</noscript>
app/frontend/src/components/BulkAnnotatePanel.js CHANGED
@@ -9,6 +9,7 @@ import {
9
  CloudDownload as CloudDownloadIcon,
10
  Save as SaveIcon,
11
  FolderOpen as FolderOpenIcon,
 
12
  } from 'lucide-react';
13
  import api from '../api';
14
 
@@ -24,7 +25,16 @@ export default function BulkAnnotatePanel({ onCommitted }) {
24
  const [message, setMessage] = useState('');
25
  const [error, setError] = useState('');
26
  const [committing, setCommitting] = useState(false);
 
 
27
  const pollRef = useRef(null);
 
 
 
 
 
 
 
28
 
29
  const stopPolling = useCallback(() => {
30
  if (pollRef.current) {
@@ -101,6 +111,37 @@ export default function BulkAnnotatePanel({ onCommitted }) {
101
  }
102
  };
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  const downloadClap = async () => {
105
  setError('');
106
  try {
@@ -173,14 +214,36 @@ export default function BulkAnnotatePanel({ onCommitted }) {
173
  disabled={isRunning}
174
  InputProps={{ readOnly: true }}
175
  />
176
- <Button
177
- variant="outlined"
178
- onClick={pickFolder}
179
- startIcon={<FolderOpenIcon size={16} />}
180
- disabled={isRunning}
181
- >
182
- Browse
183
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  <FormControl size="small" sx={{ minWidth: 140 }} disabled={isRunning}>
185
  <InputLabel id="tier-label">Tier</InputLabel>
186
  <Select
 
9
  CloudDownload as CloudDownloadIcon,
10
  Save as SaveIcon,
11
  FolderOpen as FolderOpenIcon,
12
+ Upload as UploadIcon,
13
  } from 'lucide-react';
14
  import api from '../api';
15
 
 
25
  const [message, setMessage] = useState('');
26
  const [error, setError] = useState('');
27
  const [committing, setCommitting] = useState(false);
28
+ const [isDocker, setIsDocker] = useState(false);
29
+ const [uploading, setUploading] = useState(false);
30
  const pollRef = useRef(null);
31
+ const folderInputRef = useRef(null);
32
+
33
+ useEffect(() => {
34
+ api.get('/api/environment')
35
+ .then(({ data }) => setIsDocker(!!data?.docker))
36
+ .catch(() => {});
37
+ }, []);
38
 
39
  const stopPolling = useCallback(() => {
40
  if (pollRef.current) {
 
111
  }
112
  };
113
 
114
+ const openFolderUpload = () => {
115
+ setError('');
116
+ if (folderInputRef.current) {
117
+ folderInputRef.current.value = '';
118
+ folderInputRef.current.click();
119
+ }
120
+ };
121
+
122
+ const handleFolderSelected = async (event) => {
123
+ const fileList = Array.from(event.target.files || []);
124
+ if (fileList.length === 0) return;
125
+
126
+ setError('');
127
+ setUploading(true);
128
+ try {
129
+ const form = new FormData();
130
+ fileList.forEach((file) => {
131
+ form.append('files', file);
132
+ form.append('rel_paths', file.webkitRelativePath || file.name);
133
+ });
134
+ const { data } = await api.post('/api/upload-folder', form, {
135
+ headers: { 'Content-Type': 'multipart/form-data' },
136
+ });
137
+ if (data?.path) setFolderPath(data.path);
138
+ } catch (exc) {
139
+ setError(exc.response?.data?.error || exc.message);
140
+ } finally {
141
+ setUploading(false);
142
+ }
143
+ };
144
+
145
  const downloadClap = async () => {
146
  setError('');
147
  try {
 
214
  disabled={isRunning}
215
  InputProps={{ readOnly: true }}
216
  />
217
+ {isDocker ? (
218
+ <>
219
+ <input
220
+ ref={folderInputRef}
221
+ type="file"
222
+ webkitdirectory=""
223
+ directory=""
224
+ multiple
225
+ style={{ display: 'none' }}
226
+ onChange={handleFolderSelected}
227
+ />
228
+ <Button
229
+ variant="outlined"
230
+ onClick={openFolderUpload}
231
+ startIcon={uploading ? <CircularProgress size={16} /> : <UploadIcon size={16} />}
232
+ disabled={isRunning || uploading}
233
+ >
234
+ {uploading ? 'Uploading…' : 'Upload Folder'}
235
+ </Button>
236
+ </>
237
+ ) : (
238
+ <Button
239
+ variant="outlined"
240
+ onClick={pickFolder}
241
+ startIcon={<FolderOpenIcon size={16} />}
242
+ disabled={isRunning}
243
+ >
244
+ Browse
245
+ </Button>
246
+ )}
247
  <FormControl size="small" sx={{ minWidth: 140 }} disabled={isRunning}>
248
  <InputLabel id="tier-label">Tier</InputLabel>
249
  <Select
app/frontend/src/components/HfAuthDialog.js CHANGED
@@ -34,7 +34,6 @@ const HfAuthDialog = ({ open, onClose, onModelsDownloaded }) => {
34
  if (open) {
35
  checkModelStatus();
36
  } else {
37
- // Reset state on close
38
  setActiveStep(0);
39
  setError(null);
40
  setToken('');
@@ -55,7 +54,6 @@ const HfAuthDialog = ({ open, onClose, onModelsDownloaded }) => {
55
  setMissingModels(missing);
56
 
57
  if (missing.length === 0) {
58
- // All models exist
59
  setActiveStep(3);
60
  } else {
61
  setActiveStep(1);
@@ -77,8 +75,7 @@ const HfAuthDialog = ({ open, onClose, onModelsDownloaded }) => {
77
  setError(null);
78
  try {
79
  await api.post('/api/hf-login', { token: token.trim() });
80
-
81
- // If login successful, move to download
82
  setActiveStep(2);
83
  startDownloads();
84
  } catch (err) {
@@ -91,14 +88,10 @@ const HfAuthDialog = ({ open, onClose, onModelsDownloaded }) => {
91
  try {
92
  for (const model of missingModels) {
93
  setDownloadingModel(model.name);
94
-
95
- // Record terms acceptance
96
  await api.post(`/api/models/${model.id}/accept-terms`);
97
-
98
  await api.post(`/api/models/${model.id}/download`);
99
  }
100
-
101
- // All done
102
  setActiveStep(3);
103
  if (onModelsDownloaded) {
104
  onModelsDownloaded();
@@ -113,10 +106,9 @@ const HfAuthDialog = ({ open, onClose, onModelsDownloaded }) => {
113
 
114
  const handleClose = () => {
115
  if (isProcessing && activeStep === 2) {
116
- // Cannot close while downloading
117
  return;
118
  }
119
- onClose(activeStep === 3); // return true if finished successfully
120
  };
121
 
122
  const getStepContent = (stepIndex) => {
 
34
  if (open) {
35
  checkModelStatus();
36
  } else {
 
37
  setActiveStep(0);
38
  setError(null);
39
  setToken('');
 
54
  setMissingModels(missing);
55
 
56
  if (missing.length === 0) {
 
57
  setActiveStep(3);
58
  } else {
59
  setActiveStep(1);
 
75
  setError(null);
76
  try {
77
  await api.post('/api/hf-login', { token: token.trim() });
78
+
 
79
  setActiveStep(2);
80
  startDownloads();
81
  } catch (err) {
 
88
  try {
89
  for (const model of missingModels) {
90
  setDownloadingModel(model.name);
 
 
91
  await api.post(`/api/models/${model.id}/accept-terms`);
 
92
  await api.post(`/api/models/${model.id}/download`);
93
  }
94
+
 
95
  setActiveStep(3);
96
  if (onModelsDownloaded) {
97
  onModelsDownloaded();
 
106
 
107
  const handleClose = () => {
108
  if (isProcessing && activeStep === 2) {
 
109
  return;
110
  }
111
+ onClose(activeStep === 3);
112
  };
113
 
114
  const getStepContent = (stepIndex) => {
utils/exceptions.py CHANGED
@@ -115,7 +115,6 @@ class TrainingError(FragmentaError):
115
 
116
  super().__init__(message, details)
117
 
118
- # Exception mapping for common errors
119
  def map_common_exception(exception: Exception, context: str = None) -> FragmentaError:
120
 
121
  if isinstance(exception, FileNotFoundError):
 
115
 
116
  super().__init__(message, details)
117
 
 
118
  def map_common_exception(exception: Exception, context: str = None) -> FragmentaError:
119
 
120
  if isinstance(exception, FileNotFoundError):
utils/logger.py CHANGED
@@ -1,8 +1,3 @@
1
- """
2
- Centralized Logging System for Fragmenta Desktop
3
- Replaces scattered print statements with structured logging
4
- """
5
-
6
  import logging
7
  import sys
8
  from pathlib import Path
@@ -10,8 +5,6 @@ from typing import Optional
10
  from datetime import datetime
11
  import os
12
 
13
- # Color codes for console output
14
-
15
 
16
  class Colors:
17
  RESET = '\033[0m'
@@ -25,8 +18,6 @@ class Colors:
25
 
26
 
27
  class ColoredFormatter(logging.Formatter):
28
- """Custom formatter that adds colors to log levels"""
29
-
30
  COLORS = {
31
  'DEBUG': Colors.CYAN,
32
  'INFO': Colors.GREEN,
 
 
 
 
 
 
1
  import logging
2
  import sys
3
  from pathlib import Path
 
5
  from datetime import datetime
6
  import os
7
 
 
 
8
 
9
  class Colors:
10
  RESET = '\033[0m'
 
18
 
19
 
20
  class ColoredFormatter(logging.Formatter):
 
 
21
  COLORS = {
22
  'DEBUG': Colors.CYAN,
23
  'INFO': Colors.GREEN,