GameSentenceMiner 2.19.16__py3-none-any.whl → 2.20.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.

Potentially problematic release.


This version of GameSentenceMiner might be problematic. Click here for more details.

Files changed (70) hide show
  1. GameSentenceMiner/__init__.py +39 -0
  2. GameSentenceMiner/anki.py +6 -3
  3. GameSentenceMiner/gametext.py +13 -2
  4. GameSentenceMiner/gsm.py +40 -3
  5. GameSentenceMiner/locales/en_us.json +4 -0
  6. GameSentenceMiner/locales/ja_jp.json +4 -0
  7. GameSentenceMiner/locales/zh_cn.json +4 -0
  8. GameSentenceMiner/obs.py +4 -1
  9. GameSentenceMiner/owocr/owocr/ocr.py +304 -134
  10. GameSentenceMiner/owocr/owocr/run.py +1 -1
  11. GameSentenceMiner/ui/anki_confirmation.py +4 -2
  12. GameSentenceMiner/ui/config_gui.py +12 -0
  13. GameSentenceMiner/util/configuration.py +6 -2
  14. GameSentenceMiner/util/cron/__init__.py +12 -0
  15. GameSentenceMiner/util/cron/daily_rollup.py +613 -0
  16. GameSentenceMiner/util/cron/jiten_update.py +397 -0
  17. GameSentenceMiner/util/cron/populate_games.py +154 -0
  18. GameSentenceMiner/util/cron/run_crons.py +148 -0
  19. GameSentenceMiner/util/cron/setup_populate_games_cron.py +118 -0
  20. GameSentenceMiner/util/cron_table.py +334 -0
  21. GameSentenceMiner/util/db.py +236 -49
  22. GameSentenceMiner/util/ffmpeg.py +23 -4
  23. GameSentenceMiner/util/games_table.py +340 -93
  24. GameSentenceMiner/util/jiten_api_client.py +188 -0
  25. GameSentenceMiner/util/stats_rollup_table.py +216 -0
  26. GameSentenceMiner/web/anki_api_endpoints.py +438 -220
  27. GameSentenceMiner/web/database_api.py +955 -1259
  28. GameSentenceMiner/web/jiten_database_api.py +1015 -0
  29. GameSentenceMiner/web/rollup_stats.py +672 -0
  30. GameSentenceMiner/web/static/css/dashboard-shared.css +75 -13
  31. GameSentenceMiner/web/static/css/overview.css +604 -47
  32. GameSentenceMiner/web/static/css/search.css +226 -0
  33. GameSentenceMiner/web/static/css/shared.css +762 -0
  34. GameSentenceMiner/web/static/css/stats.css +221 -0
  35. GameSentenceMiner/web/static/js/components/bar-chart.js +339 -0
  36. GameSentenceMiner/web/static/js/database-bulk-operations.js +320 -0
  37. GameSentenceMiner/web/static/js/database-game-data.js +390 -0
  38. GameSentenceMiner/web/static/js/database-game-operations.js +213 -0
  39. GameSentenceMiner/web/static/js/database-helpers.js +44 -0
  40. GameSentenceMiner/web/static/js/database-jiten-integration.js +750 -0
  41. GameSentenceMiner/web/static/js/database-popups.js +89 -0
  42. GameSentenceMiner/web/static/js/database-tabs.js +64 -0
  43. GameSentenceMiner/web/static/js/database-text-management.js +371 -0
  44. GameSentenceMiner/web/static/js/database.js +86 -718
  45. GameSentenceMiner/web/static/js/goals.js +79 -18
  46. GameSentenceMiner/web/static/js/heatmap.js +29 -23
  47. GameSentenceMiner/web/static/js/overview.js +1205 -339
  48. GameSentenceMiner/web/static/js/regex-patterns.js +100 -0
  49. GameSentenceMiner/web/static/js/search.js +215 -18
  50. GameSentenceMiner/web/static/js/shared.js +193 -39
  51. GameSentenceMiner/web/static/js/stats.js +1536 -179
  52. GameSentenceMiner/web/stats.py +1142 -269
  53. GameSentenceMiner/web/stats_api.py +2104 -0
  54. GameSentenceMiner/web/templates/anki_stats.html +4 -18
  55. GameSentenceMiner/web/templates/components/date-range.html +118 -3
  56. GameSentenceMiner/web/templates/components/html-head.html +40 -6
  57. GameSentenceMiner/web/templates/components/js-config.html +8 -8
  58. GameSentenceMiner/web/templates/components/regex-input.html +160 -0
  59. GameSentenceMiner/web/templates/database.html +564 -117
  60. GameSentenceMiner/web/templates/goals.html +41 -5
  61. GameSentenceMiner/web/templates/overview.html +159 -129
  62. GameSentenceMiner/web/templates/search.html +78 -9
  63. GameSentenceMiner/web/templates/stats.html +159 -5
  64. GameSentenceMiner/web/texthooking_page.py +280 -111
  65. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/METADATA +43 -2
  66. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/RECORD +70 -47
  67. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/WHEEL +0 -0
  68. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/entry_points.txt +0 -0
  69. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/licenses/LICENSE +0 -0
  70. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/top_level.txt +0 -0
@@ -211,9 +211,29 @@ class SQLiteDBTable:
211
211
  elif field_type is int:
212
212
  setattr(obj, field, int(row_value) if row_value is not None else None)
213
213
  elif field_type is float:
214
- setattr(obj, field, float(row_value) if row_value is not None else None)
214
+ if row_value is None:
215
+ setattr(obj, field, None)
216
+ elif isinstance(row_value, str):
217
+ # Try to parse datetime strings to Unix timestamp
218
+ try:
219
+ # First try direct float conversion
220
+ setattr(obj, field, float(row_value))
221
+ except ValueError:
222
+ # If that fails, try parsing as datetime string
223
+ try:
224
+ from datetime import datetime
225
+ dt = datetime.fromisoformat(row_value.replace(' ', 'T'))
226
+ setattr(obj, field, dt.timestamp())
227
+ except (ValueError, AttributeError):
228
+ # If all parsing fails, set to None
229
+ logger.warning(f"Could not convert '{row_value}' to float or datetime, setting to None")
230
+ setattr(obj, field, None)
231
+ else:
232
+ setattr(obj, field, float(row_value))
215
233
  elif field_type is bool:
216
- setattr(obj, field, bool(row_value) if row_value is not None else None)
234
+ # Convert from SQLite: 0/1 (int), '0'/'1' (str), or None -> bool
235
+ # Default to False for None/empty, True only for 1 or '1'
236
+ setattr(obj, field, row_value == 1 or row_value == '1')
217
237
  elif field_type is dict:
218
238
  try:
219
239
  setattr(obj, field, json.loads(row_value) if row_value else {})
@@ -225,8 +245,12 @@ class SQLiteDBTable:
225
245
  def save(self, retry=1):
226
246
  try:
227
247
  for field in self._fields:
228
- if isinstance(getattr(self, field), list):
229
- setattr(self, field, json.dumps(getattr(self, field)))
248
+ field_value = getattr(self, field)
249
+ if isinstance(field_value, list):
250
+ setattr(self, field, json.dumps(field_value))
251
+ elif isinstance(field_value, bool):
252
+ # Convert boolean to integer (0 or 1) for SQLite storage
253
+ setattr(self, field, 1 if field_value else 0)
230
254
  data = {field: getattr(self, field) for field in self._fields}
231
255
  pk_val = getattr(self, self._pk, None)
232
256
  if pk_val is None:
@@ -266,6 +290,15 @@ class SQLiteDBTable:
266
290
  raise ValueError(
267
291
  f"Primary key {self._pk} must be set for non-auto-increment tables.")
268
292
  else:
293
+ # Serialize list and dict fields to JSON, convert booleans to integers
294
+ for field in self._fields:
295
+ field_value = getattr(self, field)
296
+ if isinstance(field_value, (list, dict)):
297
+ setattr(self, field, json.dumps(field_value))
298
+ elif isinstance(field_value, bool):
299
+ # Convert boolean to integer (0 or 1) for SQLite storage
300
+ setattr(self, field, 1 if field_value else 0)
301
+
269
302
  keys = ', '.join(self._fields + [self._pk])
270
303
  placeholders = ', '.join(['?'] * (len(self._fields) + 1))
271
304
  values = tuple(getattr(self, field)
@@ -530,9 +563,10 @@ class GameLinesTable(SQLiteDBTable):
530
563
  logger.debug(f"Updated GameLine id={line_id} paths.")
531
564
 
532
565
  @classmethod
533
- def add_line(cls, gameline: GameLine):
566
+ def add_line(cls, gameline: GameLine, game_id: Optional[str] = None):
534
567
  new_line = cls(id=gameline.id, game_name=gameline.scene,
535
- line_text=gameline.text, timestamp=gameline.time.timestamp())
568
+ line_text=gameline.text, timestamp=gameline.time.timestamp(),
569
+ game_id=game_id if game_id else '')
536
570
  # logger.info("Adding GameLine to DB: %s", new_line)
537
571
  new_line.add()
538
572
  return new_line
@@ -581,47 +615,6 @@ class GameLinesTable(SQLiteDBTable):
581
615
  clean_columns = ['line_text'] if for_stats else []
582
616
  return [cls.from_row(row, clean_columns=clean_columns) for row in rows]
583
617
 
584
- class StatsRollupTable(SQLiteDBTable):
585
- _table = 'stats_rollup'
586
- _fields = ['date', 'games_played', 'lines_mined', 'anki_cards_created', 'time_spent_mining']
587
- _types = [int, # Includes primary key type
588
- str, int, int, int, float]
589
- _pk = 'id'
590
- _auto_increment = True # Use auto-incrementing integer IDs
591
-
592
- def __init__(self, id: Optional[int] = None,
593
- date: Optional[str] = None,
594
- games_played: int = 0,
595
- lines_mined: int = 0,
596
- anki_cards_created: int = 0,
597
- time_spent_mining: float = 0.0):
598
- self.id = id
599
- self.date = date if date is not None else datetime.now().strftime("%Y-%m-%d")
600
- self.games_played = games_played
601
- self.lines_mined = lines_mined
602
- self.anki_cards_created = anki_cards_created
603
- self.time_spent_mining = time_spent_mining
604
-
605
- @classmethod
606
- def get_stats_for_date(cls, date: str) -> Optional['StatsRollupTable']:
607
- row = cls._db.fetchone(
608
- f"SELECT * FROM {cls._table} WHERE date=?", (date,))
609
- return cls.from_row(row) if row else None
610
-
611
- @classmethod
612
- def update_stats(cls, date: str, games_played: int = 0, lines_mined: int = 0, anki_cards_created: int = 0, time_spent_mining: float = 0.0):
613
- stats = cls.get_stats_for_date(date)
614
- if not stats:
615
- new_stats = cls(date=date, games_played=games_played,
616
- lines_mined=lines_mined, anki_cards_created=anki_cards_created, time_spent_mining=time_spent_mining)
617
- new_stats.save()
618
- return
619
- stats.games_played += games_played
620
- stats.lines_mined += lines_mined
621
- stats.anki_cards_created += anki_cards_created
622
- stats.time_spent_mining += time_spent_mining
623
- stats.save()
624
-
625
618
  # Ensure database directory exists and return path
626
619
  def get_db_directory(test=False, delete_test=False) -> str:
627
620
  if platform == 'win32': # Windows
@@ -640,6 +633,16 @@ def get_db_directory(test=False, delete_test=False) -> str:
640
633
 
641
634
  # Backup and compress the database on load, with today's date, up to 5 days ago (clean up old backups)
642
635
  def backup_db(db_path: str):
636
+
637
+ # Create a backup of the backups on migration
638
+ pre_jiten_merge_backup = os.path.join(os.path.dirname(db_path), "backup", "database", "pre_jiten")
639
+ if not os.path.exists(pre_jiten_merge_backup):
640
+ os.makedirs(pre_jiten_merge_backup, exist_ok=True)
641
+ for fname in os.listdir(os.path.join(os.path.dirname(db_path), "backup", "database")):
642
+ fpath = os.path.join(os.path.dirname(db_path), "backup", "database", fname)
643
+ if os.path.isfile(fpath):
644
+ shutil.copy2(fpath, pre_jiten_merge_backup)
645
+
643
646
  backup_dir = os.path.join(os.path.dirname(db_path), "backup", "database")
644
647
  os.makedirs(backup_dir, exist_ok=True)
645
648
  today = time.strftime("%Y-%m-%d")
@@ -675,10 +678,12 @@ if os.path.exists(db_path):
675
678
 
676
679
  gsm_db = SQLiteDB(db_path)
677
680
 
678
- # Import GamesTable after gsm_db is created to avoid circular import
681
+ # Import GamesTable, CronTable, and StatsRollupTable after gsm_db is created to avoid circular import
679
682
  from GameSentenceMiner.util.games_table import GamesTable
683
+ from GameSentenceMiner.util.cron_table import CronTable
684
+ from GameSentenceMiner.util.stats_rollup_table import StatsRollupTable
680
685
 
681
- for cls in [AIModelsTable, GameLinesTable, GamesTable]:
686
+ for cls in [AIModelsTable, GameLinesTable, GamesTable, CronTable, StatsRollupTable]:
682
687
  cls.set_db(gsm_db)
683
688
  # Uncomment to start fresh every time
684
689
  # cls.drop()
@@ -700,7 +705,189 @@ def check_and_run_migrations():
700
705
  GameLinesTable.alter_column_type('timestamp_old', 'timestamp', 'REAL')
701
706
  logger.info("Migrated 'timestamp' column to REAL type in GameLinesTable.")
702
707
 
708
+ def migrate_obs_scene_name():
709
+ """
710
+ Add obs_scene_name column to games table and populate it from game_lines.
711
+ This migration ensures existing games have their OBS scene names preserved.
712
+ """
713
+ if not GamesTable.has_column('obs_scene_name'):
714
+ logger.info("Adding 'obs_scene_name' column to games table...")
715
+ GamesTable._db.execute(
716
+ f"ALTER TABLE {GamesTable._table} ADD COLUMN obs_scene_name TEXT",
717
+ commit=True
718
+ )
719
+ logger.info("Added 'obs_scene_name' column to games table.")
720
+
721
+ # Populate obs_scene_name for existing games by querying game_lines
722
+ logger.info("Populating obs_scene_name from game_lines...")
723
+ all_games = GamesTable.all()
724
+ updated_count = 0
725
+
726
+ for game in all_games:
727
+ # Find the first game_line with this game_id to get the original game_name
728
+ result = GameLinesTable._db.fetchone(
729
+ f"SELECT game_name FROM {GameLinesTable._table} WHERE game_id=? LIMIT 1",
730
+ (game.id,)
731
+ )
732
+
733
+ if result and result[0]:
734
+ obs_scene_name = result[0]
735
+ # Update the game with the obs_scene_name
736
+ GamesTable._db.execute(
737
+ f"UPDATE {GamesTable._table} SET obs_scene_name=? WHERE id=?",
738
+ (obs_scene_name, game.id),
739
+ commit=True
740
+ )
741
+ updated_count += 1
742
+ logger.debug(f"Set obs_scene_name='{obs_scene_name}' for game id={game.id}")
743
+
744
+ logger.info(f"Migration complete: Updated {updated_count} games with obs_scene_name from game_lines.")
745
+ else:
746
+ logger.debug("obs_scene_name column already exists in games table, skipping migration.")
747
+ """
748
+ Convert datetime strings in cron_table to Unix timestamps.
749
+ This migration handles legacy data that may have datetime strings instead of floats.
750
+ """
751
+ try:
752
+ # Get all rows directly from database to check for datetime strings
753
+ rows = CronTable._db.fetchall(f"SELECT id, last_run, next_run, created_at FROM {CronTable._table}")
754
+
755
+ updates_needed = []
756
+ for row in rows:
757
+ cron_id, last_run, next_run, created_at = row
758
+ needs_update = False
759
+ new_last_run = last_run
760
+ new_next_run = next_run
761
+ new_created_at = created_at
762
+
763
+ # Check and convert last_run
764
+ if last_run and isinstance(last_run, str) and not last_run.replace('.', '', 1).isdigit():
765
+ try:
766
+ dt = datetime.fromisoformat(last_run.replace(' ', 'T'))
767
+ new_last_run = dt.timestamp()
768
+ needs_update = True
769
+ except (ValueError, AttributeError):
770
+ logger.warning(f"Could not parse last_run '{last_run}' for cron id={cron_id}")
771
+ new_last_run = None
772
+ needs_update = True
773
+
774
+ # Check and convert next_run
775
+ if next_run and isinstance(next_run, str) and not next_run.replace('.', '', 1).isdigit():
776
+ try:
777
+ dt = datetime.fromisoformat(next_run.replace(' ', 'T'))
778
+ new_next_run = dt.timestamp()
779
+ needs_update = True
780
+ except (ValueError, AttributeError):
781
+ logger.warning(f"Could not parse next_run '{next_run}' for cron id={cron_id}")
782
+ new_next_run = time.time()
783
+ needs_update = True
784
+
785
+ # Check and convert created_at
786
+ if created_at and isinstance(created_at, str) and not created_at.replace('.', '', 1).isdigit():
787
+ try:
788
+ dt = datetime.fromisoformat(created_at.replace(' ', 'T'))
789
+ new_created_at = dt.timestamp()
790
+ needs_update = True
791
+ except (ValueError, AttributeError):
792
+ logger.warning(f"Could not parse created_at '{created_at}' for cron id={cron_id}")
793
+ new_created_at = time.time()
794
+ needs_update = True
795
+
796
+ if needs_update:
797
+ updates_needed.append((new_last_run, new_next_run, new_created_at, cron_id))
798
+
799
+ # Apply updates
800
+ if updates_needed:
801
+ logger.info(f"Migrating {len(updates_needed)} cron entries with datetime strings to Unix timestamps...")
802
+ for new_last_run, new_next_run, new_created_at, cron_id in updates_needed:
803
+ CronTable._db.execute(
804
+ f"UPDATE {CronTable._table} SET last_run=?, next_run=?, created_at=? WHERE id=?",
805
+ (new_last_run, new_next_run, new_created_at, cron_id),
806
+ commit=True
807
+ )
808
+ logger.info(f"✅ Migrated {len(updates_needed)} cron entries to Unix timestamps")
809
+ else:
810
+ logger.debug("No cron timestamp migration needed")
811
+
812
+ except Exception as e:
813
+ logger.error(f"Error during cron timestamp migration: {e}")
814
+
815
+ def migrate_jiten_cron_job():
816
+ """
817
+ Create the monthly jiten.moe update cron job if it doesn't exist.
818
+ This ensures the cron job is automatically registered on database initialization.
819
+ """
820
+ existing_cron = CronTable.get_by_name('jiten_sync')
821
+ if not existing_cron:
822
+ logger.info("Creating monthly jiten.moe update cron job...")
823
+ # Calculate next run: first day of next month at midnight
824
+ now = datetime.now()
825
+ if now.month == 12:
826
+ next_month = datetime(now.year + 1, 1, 1, 0, 0, 0)
827
+ else:
828
+ next_month = datetime(now.year, now.month + 1, 1, 0, 0, 0)
829
+
830
+ CronTable.create_cron_entry(
831
+ name='jiten_sync',
832
+ description='Automatically update all linked games from jiten.moe database (respects manual overrides)',
833
+ next_run=next_month.timestamp(),
834
+ schedule='monthly'
835
+ )
836
+ logger.info(f"✅ Created jiten_sync cron job - next run: {next_month.strftime('%Y-%m-%d %H:%M:%S')}")
837
+ else:
838
+ logger.debug("jiten_sync cron job already exists, skipping creation.")
839
+
840
+ def migrate_daily_rollup_cron_job():
841
+ """
842
+ Create the daily statistics rollup cron job if it doesn't exist.
843
+ This ensures the cron job is automatically registered on database initialization.
844
+ """
845
+ existing_cron = CronTable.get_by_name('daily_stats_rollup')
846
+ if not existing_cron:
847
+ logger.info("Creating daily statistics rollup cron job...")
848
+ # Schedule for 1 minute ago to ensure it runs immediately on first startup
849
+ now = datetime.now()
850
+ one_minute_ago = now - timedelta(minutes=1)
851
+
852
+ CronTable.create_cron_entry(
853
+ name='daily_stats_rollup',
854
+ description='Roll up daily statistics for all dates up to yesterday',
855
+ next_run=one_minute_ago.timestamp(),
856
+ schedule='daily'
857
+ )
858
+ logger.info(f"✅ Created daily_stats_rollup cron job - scheduled to run immediately (next_run: {one_minute_ago.strftime('%Y-%m-%d %H:%M:%S')})")
859
+ else:
860
+ logger.debug("daily_stats_rollup cron job already exists, skipping creation.")
861
+
862
+ def migrate_populate_games_cron_job():
863
+ """
864
+ Create the one-time populate_games cron job if it doesn't exist.
865
+ This ensures games table is populated before the daily rollup runs.
866
+ Runs once and auto-disables (schedule='once').
867
+ """
868
+ existing_cron = CronTable.get_by_name('populate_games')
869
+ if not existing_cron:
870
+ logger.info("Creating one-time populate_games cron job...")
871
+ # Schedule to run immediately (2 minutes ago to ensure it runs before rollup)
872
+ now = datetime.now()
873
+ two_minutes_ago = now - timedelta(minutes=2)
874
+
875
+ CronTable.create_cron_entry(
876
+ name='populate_games',
877
+ description='One-time auto-creation of game records from game_lines (runs before rollup)',
878
+ next_run=two_minutes_ago.timestamp(),
879
+ schedule='weekly' # Will auto-disable after running
880
+ )
881
+ logger.info(f"✅ Created populate_games cron job - scheduled to run immediately (next_run: {two_minutes_ago.strftime('%Y-%m-%d %H:%M:%S')})")
882
+ else:
883
+ logger.debug("populate_games cron job already exists, skipping creation.")
884
+
703
885
  migrate_timestamp()
886
+ migrate_obs_scene_name()
887
+ # migrate_cron_timestamps() # Disabled - user will manually clean up data
888
+ migrate_jiten_cron_job()
889
+ migrate_populate_games_cron_job() # Run BEFORE daily_rollup to ensure games exist
890
+ migrate_daily_rollup_cron_job()
704
891
 
705
892
  check_and_run_migrations()
706
893
 
@@ -228,9 +228,19 @@ def get_screenshot(video_file, screenshot_timing, try_selector=False):
228
228
  output_image = make_unique_file_name(os.path.join(
229
229
  get_temporary_directory(), f"{obs.get_current_game(sanitize=True)}.{get_config().screenshot.extension}"))
230
230
 
231
+ pre_input_args = []
232
+ pre_input_args_string = ""
233
+ if get_config().screenshot.custom_ffmpeg_settings:
234
+ if '-hwaccel' in get_config().screenshot.custom_ffmpeg_settings:
235
+ hwaccel_args = get_config().screenshot.custom_ffmpeg_settings.split(" ")
236
+ hwaccel_index = hwaccel_args.index('-hwaccel')
237
+ pre_input_args.extend(hwaccel_args[hwaccel_index:hwaccel_index + 2])
238
+ pre_input_args_string = " ".join(pre_input_args)
239
+
231
240
  # Base command for extracting the frame
232
241
  ffmpeg_command = ffmpeg_base_command_list + [
233
242
  "-ss", f"{screenshot_timing}",
243
+ ] + pre_input_args + [
234
244
  "-i", f"{video_file}",
235
245
  "-vframes", "1" # Extract only one frame
236
246
  ]
@@ -252,7 +262,7 @@ def get_screenshot(video_file, screenshot_timing, try_selector=False):
252
262
  ffmpeg_command.extend(["-vf", ",".join(video_filters)])
253
263
 
254
264
  if get_config().screenshot.custom_ffmpeg_settings:
255
- ffmpeg_command.extend(get_config().screenshot.custom_ffmpeg_settings.replace("\"", "").split(" "))
265
+ ffmpeg_command.extend(get_config().screenshot.custom_ffmpeg_settings.replace("\"", "").replace(pre_input_args_string, "").split())
256
266
  else:
257
267
  # Ensure quality settings are strings
258
268
  ffmpeg_command.extend(["-compression_level", "6", "-q:v", str(get_config().screenshot.quality)])
@@ -598,14 +608,23 @@ def get_screenshot_time(video_path, game_line, default_beginning=False, vad_resu
598
608
  def process_image(image_file):
599
609
  output_image = make_unique_file_name(
600
610
  os.path.join(get_temporary_directory(), f"{obs.get_current_game(sanitize=True)}.{get_config().screenshot.extension}"))
611
+
612
+ pre_input_args = []
613
+ pre_input_args_string = ""
614
+ if get_config().screenshot.custom_ffmpeg_settings:
615
+ if '-hwaccel' in get_config().screenshot.custom_ffmpeg_settings:
616
+ hwaccel_args = get_config().screenshot.custom_ffmpeg_settings.split()
617
+ hwaccel_index = hwaccel_args.index('-hwaccel')
618
+ pre_input_args.extend(hwaccel_args[hwaccel_index:hwaccel_index + 2])
619
+ pre_input_args_string = " ".join(pre_input_args)
601
620
 
602
621
  # FFmpeg command to process the input image
603
- ffmpeg_command = ffmpeg_base_command_list + [
622
+ ffmpeg_command = ffmpeg_base_command_list + pre_input_args + [
604
623
  "-i", image_file
605
624
  ]
606
625
 
607
626
  if get_config().screenshot.custom_ffmpeg_settings:
608
- ffmpeg_command.extend(get_config().screenshot.custom_ffmpeg_settings.split(" "))
627
+ ffmpeg_command.extend(get_config().screenshot.custom_ffmpeg_settings.replace(pre_input_args_string, "").split())
609
628
  else:
610
629
  ffmpeg_command.extend(["-compression_level", "6", "-q:v", get_config().screenshot.quality])
611
630
 
@@ -769,7 +788,7 @@ def reencode_file_with_user_config(input_file, final_output_audio, user_ffmpeg_o
769
788
  command = ffmpeg_base_command_list + [
770
789
  "-i", input_file,
771
790
  "-map", "0:a"
772
- ] + user_ffmpeg_options.replace("\"", "").split(" ") + [
791
+ ] + user_ffmpeg_options.replace("\"", "").split() + [
773
792
  temp_file
774
793
  ]
775
794