appydave-tools 0.11.10 → 0.12.0
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.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +14 -0
- data/bin/move_images.rb +62 -0
- data/bin/subtitle_master-old.rb +113 -0
- data/bin/subtitle_master.rb +94 -55
- data/lib/appydave/tools/subtitle_master/_doc-join.md +166 -0
- data/lib/appydave/tools/subtitle_master/clean.rb +11 -0
- data/lib/appydave/tools/subtitle_master/join.rb +327 -0
- data/lib/appydave/tools/version.rb +1 -1
- data/lib/appydave/tools.rb +1 -5
- data/package-lock.json +2 -2
- data/package.json +1 -1
- metadata +7 -3
- /data/lib/appydave/tools/subtitle_master/{_doc.md → _doc-clean.md} +0 -0
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e50556323b419ef5468e5199cf148812e25ff5c6001db7d35dc7f4cd52d7bb50
|
4
|
+
data.tar.gz: 23015d1c15d2fcde38f973fed02add0b7f55d09be3acda11f06705a15fcd6fdf
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9f097fe81dae81d638bfa6040b44f9da187c95892a903a2e2c9e91c493134fd18fe0541e0b77daa99bfc56fa84378a1527d6d43f0955d74fdc038f3e9c4271a4
|
7
|
+
data.tar.gz: bdbf1c0ec19ce47042e17a16b8af96110e0e9b037fd61336debb32a5d251d80e50ce2259d4130b9bc220e9d95404b70c34b9ae9340e8ec557a1a595e0614e388
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,17 @@
|
|
1
|
+
## [0.11.11](https://github.com/appydave/appydave-tools/compare/v0.11.10...v0.11.11) (2024-11-26)
|
2
|
+
|
3
|
+
|
4
|
+
### Bug Fixes
|
5
|
+
|
6
|
+
* update clean srt specs ([e0da656](https://github.com/appydave/appydave-tools/commit/e0da656a3bf04f094bc3726a588a59d99d3d24a8))
|
7
|
+
|
8
|
+
## [0.11.10](https://github.com/appydave/appydave-tools/compare/v0.11.9...v0.11.10) (2024-11-26)
|
9
|
+
|
10
|
+
|
11
|
+
### Bug Fixes
|
12
|
+
|
13
|
+
* Add future requirement docs for SRT ([29eb02d](https://github.com/appydave/appydave-tools/commit/29eb02de587c3e61f6b92a1690dc5a3c657c0a34))
|
14
|
+
|
1
15
|
## [0.11.9](https://github.com/appydave/appydave-tools/compare/v0.11.8...v0.11.9) (2024-11-26)
|
2
16
|
|
3
17
|
|
data/bin/move_images.rb
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# CHAT: https://chatgpt.com/c/67038d52-9928-8002-8063-5616f7fe7aef
|
5
|
+
|
6
|
+
# !/usr/bin/env ruby
|
7
|
+
|
8
|
+
require 'fileutils'
|
9
|
+
require 'optparse'
|
10
|
+
|
11
|
+
# Default base directory
|
12
|
+
base_dir = '/Volumes/Expansion/Sync/tube-channels/video-projects'
|
13
|
+
|
14
|
+
# Common subfolder names (for reference, not enforced)
|
15
|
+
# Common subfolders: intro, outro, content, teaser, thumb
|
16
|
+
|
17
|
+
# Parse command-line options
|
18
|
+
options = {}
|
19
|
+
OptionParser.new do |opts|
|
20
|
+
opts.banner = "Usage: #{$PROGRAM_NAME} -f <folder> <section> <prefix>"
|
21
|
+
|
22
|
+
opts.on('-f FOLDER', '--folder FOLDER', 'Specify the subfolder under video-projects') do |folder|
|
23
|
+
options[:folder] = folder
|
24
|
+
end
|
25
|
+
end.parse!
|
26
|
+
|
27
|
+
# Ensure the folder option is provided
|
28
|
+
unless options[:folder]
|
29
|
+
puts 'You must specify a folder using the -f option'
|
30
|
+
exit 1
|
31
|
+
end
|
32
|
+
|
33
|
+
# Set source and destination directories
|
34
|
+
source_dir = File.expand_path('~/Sync/smart-downloads/download-images')
|
35
|
+
dest_dir = File.join(base_dir, options[:folder], 'assets')
|
36
|
+
|
37
|
+
# Get input parameters
|
38
|
+
section = ARGV[0]
|
39
|
+
prefix = ARGV[1]
|
40
|
+
|
41
|
+
unless prefix && section
|
42
|
+
puts "Usage: #{$PROGRAM_NAME} -f <folder> <section> <prefix>"
|
43
|
+
exit 1
|
44
|
+
end
|
45
|
+
|
46
|
+
# Ensure the section subfolder exists
|
47
|
+
section_dir = File.join(dest_dir, section)
|
48
|
+
puts "Creating subfolder if it doesn't exist: #{section_dir}"
|
49
|
+
FileUtils.mkdir_p(section_dir)
|
50
|
+
|
51
|
+
puts "Source directory: #{source_dir}"
|
52
|
+
|
53
|
+
# Find and move the images
|
54
|
+
Dir.glob("#{source_dir}/*.jpg").each_with_index do |file, index|
|
55
|
+
puts "Processing #{file}, #{index}"
|
56
|
+
new_filename = "#{prefix}-#{section}-#{index + 1}.jpg"
|
57
|
+
puts "New filename: #{new_filename}"
|
58
|
+
destination = File.join(section_dir, new_filename)
|
59
|
+
puts "Destination: #{destination}"
|
60
|
+
FileUtils.mv(file, destination)
|
61
|
+
puts "Moved #{file} to #{destination}"
|
62
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
5
|
+
|
6
|
+
require 'appydave/tools'
|
7
|
+
|
8
|
+
# Process command line arguments for SubtitleMaster operations
|
9
|
+
class SubtitleMasterCLI
|
10
|
+
def initialize
|
11
|
+
@commands = {
|
12
|
+
'clean' => method(:clean_subtitles),
|
13
|
+
'correct' => method(:correct_subtitles),
|
14
|
+
'split' => method(:split_subtitles),
|
15
|
+
'highlight' => method(:highlight_subtitles),
|
16
|
+
'image_prompts' => method(:generate_image_prompts)
|
17
|
+
}
|
18
|
+
end
|
19
|
+
|
20
|
+
def run
|
21
|
+
command, *args = ARGV
|
22
|
+
if @commands.key?(command)
|
23
|
+
@commands[command].call(args)
|
24
|
+
else
|
25
|
+
puts "Unknown command: #{command}"
|
26
|
+
print_help
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def clean_subtitles(args)
|
33
|
+
options = parse_options(args, 'clean')
|
34
|
+
cleaner = Appydave::Tools::SubtitleMaster::Clean.new(file_path: options[:file])
|
35
|
+
result = cleaner.clean
|
36
|
+
write_output(result, options[:output])
|
37
|
+
end
|
38
|
+
|
39
|
+
def correct_subtitles(args)
|
40
|
+
options = parse_options(args, 'correct')
|
41
|
+
corrector = Appydave::Tools::SubtitleMaster::Correct.new(options[:file])
|
42
|
+
result = corrector.correct
|
43
|
+
write_output(result, options[:output])
|
44
|
+
end
|
45
|
+
|
46
|
+
def split_subtitles(args)
|
47
|
+
options = parse_options(args, 'split', %i[words_per_group])
|
48
|
+
splitter = Appydave::Tools::SubtitleMaster::Split.new(options[:file], options[:words_per_group])
|
49
|
+
result = splitter.split
|
50
|
+
write_output(result, options[:output])
|
51
|
+
end
|
52
|
+
|
53
|
+
def highlight_subtitles(args)
|
54
|
+
options = parse_options(args, 'highlight')
|
55
|
+
highlighter = Appydave::Tools::SubtitleMaster::Highlight.new(options[:file])
|
56
|
+
result = highlighter.highlight
|
57
|
+
write_output(result, options[:output])
|
58
|
+
end
|
59
|
+
|
60
|
+
def generate_image_prompts(args)
|
61
|
+
options = parse_options(args, 'image_prompts')
|
62
|
+
image_prompter = Appydave::Tools::SubtitleMaster::ImagePrompts.new(options[:file])
|
63
|
+
result = image_prompter.generate_prompts
|
64
|
+
write_output(result, options[:output])
|
65
|
+
end
|
66
|
+
|
67
|
+
def parse_options(args, command, extra_options = [])
|
68
|
+
options = { file: nil, output: nil }
|
69
|
+
OptionParser.new do |opts|
|
70
|
+
opts.banner = "Usage: subtitle_master.rb #{command} [options]"
|
71
|
+
|
72
|
+
opts.on('-f', '--file FILE', 'SRT file to process') { |v| options[:file] = v }
|
73
|
+
opts.on('-o', '--output FILE', 'Output file') { |v| options[:output] = v }
|
74
|
+
|
75
|
+
extra_options.each do |opt|
|
76
|
+
case opt
|
77
|
+
when :words_per_group
|
78
|
+
opts.on('-w', '--words-per-group WORDS', 'Number of words per group for splitting') { |v| options[:words_per_group] = v.to_i }
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
opts.on_tail('-h', '--help', 'Show this message') do
|
83
|
+
puts opts
|
84
|
+
exit
|
85
|
+
end
|
86
|
+
end.parse!(args)
|
87
|
+
|
88
|
+
unless options[:file] && options[:output]
|
89
|
+
puts 'Missing required options. Use -h for help.'
|
90
|
+
exit
|
91
|
+
end
|
92
|
+
|
93
|
+
options
|
94
|
+
end
|
95
|
+
|
96
|
+
def write_output(result, output_file)
|
97
|
+
File.write(output_file, result)
|
98
|
+
puts "Processed file written to #{output_file}"
|
99
|
+
end
|
100
|
+
|
101
|
+
def print_help
|
102
|
+
puts 'Usage: subtitle_master.rb [command] [options]'
|
103
|
+
puts 'Commands:'
|
104
|
+
puts ' clean Clean and normalize SRT files'
|
105
|
+
puts ' correct Correct common typos and mistranslations in SRT files'
|
106
|
+
puts ' split Split subtitle groups based on word count'
|
107
|
+
puts ' highlight Highlight power words in subtitles'
|
108
|
+
puts ' image_prompts Generate image prompts from subtitle text'
|
109
|
+
puts "Run 'subtitle_master.rb [command] --help' for more information on a command."
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
SubtitleMasterCLI.new.run
|
data/bin/subtitle_master.rb
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
# frozen_string_literal: true
|
3
3
|
|
4
|
+
require 'optparse'
|
5
|
+
|
4
6
|
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
5
7
|
|
6
8
|
require 'appydave/tools'
|
@@ -10,15 +12,18 @@ class SubtitleMasterCLI
|
|
10
12
|
def initialize
|
11
13
|
@commands = {
|
12
14
|
'clean' => method(:clean_subtitles),
|
13
|
-
'
|
14
|
-
'split' => method(:split_subtitles),
|
15
|
-
'highlight' => method(:highlight_subtitles),
|
16
|
-
'image_prompts' => method(:generate_image_prompts)
|
15
|
+
'join' => method(:join_subtitles)
|
17
16
|
}
|
18
17
|
end
|
19
18
|
|
20
19
|
def run
|
21
20
|
command, *args = ARGV
|
21
|
+
if command.nil?
|
22
|
+
puts 'No command provided. Use -h for help.'
|
23
|
+
print_help
|
24
|
+
exit
|
25
|
+
end
|
26
|
+
|
22
27
|
if @commands.key?(command)
|
23
28
|
@commands[command].call(args)
|
24
29
|
else
|
@@ -30,82 +35,116 @@ class SubtitleMasterCLI
|
|
30
35
|
private
|
31
36
|
|
32
37
|
def clean_subtitles(args)
|
33
|
-
options =
|
38
|
+
options = { file: nil, output: nil }
|
39
|
+
|
40
|
+
# Command-specific option parser
|
41
|
+
clean_parser = OptionParser.new do |opts|
|
42
|
+
opts.banner = 'Usage: subtitle_master.rb clean [options]'
|
43
|
+
|
44
|
+
opts.on('-f', '--file FILE', 'SRT file to process') do |v|
|
45
|
+
options[:file] = v
|
46
|
+
end
|
47
|
+
|
48
|
+
opts.on('-o', '--output FILE', 'Output file') do |v|
|
49
|
+
options[:output] = v
|
50
|
+
end
|
51
|
+
|
52
|
+
opts.on('-h', '--help', 'Show this message') do
|
53
|
+
puts opts
|
54
|
+
exit
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
begin
|
59
|
+
clean_parser.parse!(args)
|
60
|
+
rescue OptionParser::InvalidOption => e
|
61
|
+
puts "Error: #{e.message}"
|
62
|
+
puts clean_parser
|
63
|
+
exit
|
64
|
+
end
|
65
|
+
|
66
|
+
# Validate required options
|
67
|
+
if options[:file].nil? || options[:output].nil?
|
68
|
+
puts 'Error: Missing required options.'
|
69
|
+
puts clean_parser
|
70
|
+
exit
|
71
|
+
end
|
72
|
+
|
73
|
+
# Assuming `Appydave::Tools::SubtitleMaster::Clean` exists
|
34
74
|
cleaner = Appydave::Tools::SubtitleMaster::Clean.new(file_path: options[:file])
|
35
|
-
|
36
|
-
|
75
|
+
cleaner.clean
|
76
|
+
cleaner.write(options[:output])
|
37
77
|
end
|
38
78
|
|
39
|
-
def
|
40
|
-
options =
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
79
|
+
def join_subtitles(args)
|
80
|
+
options = {
|
81
|
+
folder: './',
|
82
|
+
files: '*.srt',
|
83
|
+
sort: 'inferred',
|
84
|
+
buffer: 100,
|
85
|
+
output: 'merged.srt',
|
86
|
+
verbose: false
|
87
|
+
}
|
45
88
|
|
46
|
-
|
47
|
-
|
48
|
-
splitter = Appydave::Tools::SubtitleMaster::Split.new(options[:file], options[:words_per_group])
|
49
|
-
result = splitter.split
|
50
|
-
write_output(result, options[:output])
|
51
|
-
end
|
89
|
+
join_parser = OptionParser.new do |opts|
|
90
|
+
opts.banner = 'Usage: subtitle_master.rb join [options]'
|
52
91
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
result = highlighter.highlight
|
57
|
-
write_output(result, options[:output])
|
58
|
-
end
|
92
|
+
opts.on('-d', '--directory DIR', 'Directory containing SRT files (default: current directory)') do |v|
|
93
|
+
options[:folder] = v
|
94
|
+
end
|
59
95
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
result = image_prompter.generate_prompts
|
64
|
-
write_output(result, options[:output])
|
65
|
-
end
|
96
|
+
opts.on('-f', '--files PATTERN', 'File pattern (e.g., "*.srt" or "part1.srt,part2.srt")') do |v|
|
97
|
+
options[:files] = v
|
98
|
+
end
|
66
99
|
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
opts.banner = "Usage: subtitle_master.rb #{command} [options]"
|
100
|
+
opts.on('-s', '--sort ORDER', %w[asc desc inferred], 'Sort order (asc/desc/inferred)') do |v|
|
101
|
+
options[:sort] = v
|
102
|
+
end
|
71
103
|
|
72
|
-
opts.on('-
|
73
|
-
|
104
|
+
opts.on('-b', '--buffer MS', Integer, 'Buffer between merged files in milliseconds') do |v|
|
105
|
+
options[:buffer] = v
|
106
|
+
end
|
107
|
+
|
108
|
+
opts.on('-o', '--output FILE', 'Output file') do |v|
|
109
|
+
options[:output] = v
|
110
|
+
end
|
74
111
|
|
75
|
-
|
76
|
-
|
77
|
-
when :words_per_group
|
78
|
-
opts.on('-w', '--words-per-group WORDS', 'Number of words per group for splitting') { |v| options[:words_per_group] = v.to_i }
|
79
|
-
end
|
112
|
+
opts.on('-L', '--log-level LEVEL', %w[none info detail], 'Log level (default: info)') do |v|
|
113
|
+
options[:log_level] = v.to_sym
|
80
114
|
end
|
81
115
|
|
82
|
-
opts.
|
116
|
+
opts.on('-h', '--help', 'Show this message') do
|
83
117
|
puts opts
|
84
118
|
exit
|
85
119
|
end
|
86
|
-
end
|
120
|
+
end
|
87
121
|
|
88
|
-
|
89
|
-
|
122
|
+
begin
|
123
|
+
join_parser.parse!(args)
|
124
|
+
rescue OptionParser::InvalidOption => e
|
125
|
+
puts "Error: #{e.message}"
|
126
|
+
puts join_parser
|
90
127
|
exit
|
91
128
|
end
|
92
129
|
|
93
|
-
options
|
94
|
-
|
130
|
+
# Validate required options
|
131
|
+
if options[:folder].nil? || options[:files].nil? || options[:output].nil?
|
132
|
+
puts 'Error: Missing required options.'
|
133
|
+
puts join_parser
|
134
|
+
exit
|
135
|
+
end
|
95
136
|
|
96
|
-
|
97
|
-
|
98
|
-
|
137
|
+
# Assuming `Appydave::Tools::SubtitleMaster::Join` exists
|
138
|
+
joiner = Appydave::Tools::SubtitleMaster::Join.new(folder: options[:folder], files: options[:files], sort: options[:sort], buffer: options[:buffer], output: options[:output],
|
139
|
+
log_level: options[:log_level])
|
140
|
+
joiner.join
|
99
141
|
end
|
100
142
|
|
101
143
|
def print_help
|
102
144
|
puts 'Usage: subtitle_master.rb [command] [options]'
|
103
145
|
puts 'Commands:'
|
104
146
|
puts ' clean Clean and normalize SRT files'
|
105
|
-
puts '
|
106
|
-
puts ' split Split subtitle groups based on word count'
|
107
|
-
puts ' highlight Highlight power words in subtitles'
|
108
|
-
puts ' image_prompts Generate image prompts from subtitle text'
|
147
|
+
puts ' join Join multiple SRT files'
|
109
148
|
puts "Run 'subtitle_master.rb [command] --help' for more information on a command."
|
110
149
|
end
|
111
150
|
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
|
2
|
+
# Detailed Requirements Specification
|
3
|
+
|
4
|
+
## Command-Line Tool
|
5
|
+
|
6
|
+
### Purpose
|
7
|
+
A CLI tool named `Join` for merging multiple SRT files into one cohesive subtitle file, handling timestamp adjustment and preserving subtitle integrity.
|
8
|
+
|
9
|
+
---
|
10
|
+
|
11
|
+
### Parameters
|
12
|
+
|
13
|
+
1. **`--folder <path>`**
|
14
|
+
- **Purpose**: Specifies the folder containing SRT files.
|
15
|
+
- **Default**: Current working directory (`./`).
|
16
|
+
- **Example**: `--folder /path/to/subtitles`.
|
17
|
+
|
18
|
+
2. **`--files <pattern>`**
|
19
|
+
- **Purpose**: Specifies specific filenames or wildcard patterns for SRT files to process.
|
20
|
+
- **Default**: `*.srt` (processes all SRT files in the folder).
|
21
|
+
- **Logic**:
|
22
|
+
- Explicit filenames: `file1.srt file2.srt` → Process in provided order.
|
23
|
+
- Wildcard patterns: `*.srt` → Resolve matching files, sort by `--sort` order.
|
24
|
+
|
25
|
+
3. **`--sort <inferred|asc|desc>`**
|
26
|
+
- **Purpose**: Defines how files are ordered for processing.
|
27
|
+
- **Default**: `inferred`
|
28
|
+
- **Logic**:
|
29
|
+
- `inferred`:
|
30
|
+
- Explicit filenames → Preserve order.
|
31
|
+
- Wildcards → Sort alphabetically (ascending).
|
32
|
+
- `asc`: Force alphabetical order.
|
33
|
+
- `desc`: Force reverse alphabetical order.
|
34
|
+
|
35
|
+
4. **`--buffer <milliseconds>`**
|
36
|
+
- **Purpose**: Adds a buffer between the last subtitle of one file and the first subtitle of the next.
|
37
|
+
- **Default**: `100` (100ms).
|
38
|
+
- **Example**: `--buffer 50` for a 50ms gap.
|
39
|
+
|
40
|
+
5. **`--output <output_file.srt>`**
|
41
|
+
- **Purpose**: Specifies the name of the output merged SRT file.
|
42
|
+
- **Default**: `merged.srt` in the current folder.
|
43
|
+
- **Example**: `--output /path/to/final_output.srt`.
|
44
|
+
|
45
|
+
6. **`--verbose`**
|
46
|
+
- **Purpose**: Enables detailed logging for steps like file resolution, timestamp adjustments, and warnings for skipped files.
|
47
|
+
|
48
|
+
---
|
49
|
+
|
50
|
+
## Primary Component Logic
|
51
|
+
|
52
|
+
### Design Overview
|
53
|
+
The tool should adhere to **Single Responsibility Principle (SRP)**, favor **composition**, and nest the composed components within the `Join` class for encapsulation and reusability.
|
54
|
+
|
55
|
+
### Primary Class: `Join`
|
56
|
+
The `Join` class acts as the main entry point and coordinates the workflow. It contains the following nested components:
|
57
|
+
|
58
|
+
1. **`FileResolver`**
|
59
|
+
- **Responsibility**: Handles file resolution (folder, filenames, wildcards, sorting).
|
60
|
+
- **Logic**:
|
61
|
+
- Resolve files based on `--files` or `--folder`.
|
62
|
+
- Apply sorting rules (`inferred`, `asc`, `desc`).
|
63
|
+
|
64
|
+
2. **`SRTParser`**
|
65
|
+
- **Responsibility**: Parses SRT files into structured subtitle objects.
|
66
|
+
- **Logic**:
|
67
|
+
- Read and validate SRT content.
|
68
|
+
- Split into subtitle blocks (index, start time, end time, text).
|
69
|
+
- Convert timestamps into structured objects (seconds for calculations).
|
70
|
+
|
71
|
+
3. **`SRTMerger`**
|
72
|
+
- **Responsibility**: Combines subtitles, adjusts timestamps, and applies buffers.
|
73
|
+
- **Logic**:
|
74
|
+
- Aggregate parsed subtitles from multiple files.
|
75
|
+
- Adjust timestamps for non-overlapping entries using the buffer.
|
76
|
+
|
77
|
+
4. **`SRTWriter`**
|
78
|
+
- **Responsibility**: Converts subtitle objects back to SRT format and writes to disk.
|
79
|
+
- **Logic**:
|
80
|
+
- Reformat subtitles with proper numbering and timestamp formatting.
|
81
|
+
- Write the final merged content to the specified output file.
|
82
|
+
|
83
|
+
---
|
84
|
+
|
85
|
+
### Main Workflow
|
86
|
+
|
87
|
+
1. **Parse Arguments**
|
88
|
+
- Validate input parameters and resolve defaults.
|
89
|
+
|
90
|
+
2. **Resolve Files**
|
91
|
+
- Use `FileResolver` to identify files based on folder, filenames, or wildcard patterns.
|
92
|
+
|
93
|
+
3. **Parse Files**
|
94
|
+
- Use `SRTParser` to parse each file into a list of structured subtitle objects.
|
95
|
+
|
96
|
+
4. **Merge Files**
|
97
|
+
- Use `SRTMerger` to combine subtitles, sort by start time (per file order), and apply timestamp adjustments.
|
98
|
+
|
99
|
+
5. **Write Output**
|
100
|
+
- Use `SRTWriter` to generate the final SRT file and save it to the specified location.
|
101
|
+
|
102
|
+
---
|
103
|
+
|
104
|
+
## Business Rules
|
105
|
+
|
106
|
+
1. **File Selection**
|
107
|
+
- Resolve wildcards to file lists dynamically.
|
108
|
+
- Validate file existence and skip invalid or non-SRT files with appropriate warnings.
|
109
|
+
|
110
|
+
2. **Timestamp Handling**
|
111
|
+
- Ensure non-overlapping subtitles across files by applying the buffer.
|
112
|
+
- Maintain relative timing within each file.
|
113
|
+
|
114
|
+
3. **Sorting Logic**
|
115
|
+
- Preserve file order for explicit filenames.
|
116
|
+
- Apply alphabetical sorting for wildcards unless overridden by `--sort`.
|
117
|
+
|
118
|
+
4. **Error Handling**
|
119
|
+
- Log and skip malformed SRT files.
|
120
|
+
- Notify users of missing files or unsupported patterns.
|
121
|
+
|
122
|
+
5. **Output Consistency**
|
123
|
+
- Re-number subtitles sequentially in the final output.
|
124
|
+
- Ensure proper formatting with three decimal places for milliseconds.
|
125
|
+
|
126
|
+
---
|
127
|
+
|
128
|
+
## Tests
|
129
|
+
|
130
|
+
### Unit Tests
|
131
|
+
|
132
|
+
1. **FileResolver Tests**
|
133
|
+
- Resolve explicit filenames.
|
134
|
+
- Resolve wildcard patterns (`*.srt`) and sort files.
|
135
|
+
- Handle missing or invalid files gracefully.
|
136
|
+
|
137
|
+
2. **SRTParser Tests**
|
138
|
+
- Parse valid SRT files into structured objects.
|
139
|
+
- Detect and handle malformed SRT files.
|
140
|
+
- Validate timestamp formatting.
|
141
|
+
|
142
|
+
3. **SRTMerger Tests**
|
143
|
+
- Combine subtitles from two or more files.
|
144
|
+
- Ensure timestamps are adjusted with the buffer.
|
145
|
+
- Handle edge cases like overlapping timestamps or missing buffer.
|
146
|
+
|
147
|
+
4. **SRTWriter Tests**
|
148
|
+
- Convert subtitle objects to valid SRT format.
|
149
|
+
- Ensure proper numbering and formatting (e.g., three decimal places).
|
150
|
+
|
151
|
+
---
|
152
|
+
|
153
|
+
### Integration Tests
|
154
|
+
|
155
|
+
1. Process a folder of SRT files and produce a merged output.
|
156
|
+
2. Validate behavior with explicit filenames and wildcards.
|
157
|
+
3. Verify handling of buffers for different values (`0ms`, `50ms`, etc.).
|
158
|
+
4. Test edge cases like empty files, malformed SRTs, or mixed valid/invalid files.
|
159
|
+
|
160
|
+
---
|
161
|
+
|
162
|
+
### End-to-End Tests
|
163
|
+
|
164
|
+
1. Run the tool with minimal arguments and verify the output.
|
165
|
+
2. Test all parameter combinations (`--folder`, `--files`, `--sort`, `--buffer`, etc.).
|
166
|
+
3. Compare merged output against manually validated reference files.
|
@@ -5,6 +5,8 @@ module Appydave
|
|
5
5
|
module SubtitleMaster
|
6
6
|
# Clean and normalize subtitles
|
7
7
|
class Clean
|
8
|
+
attr_reader :content
|
9
|
+
|
8
10
|
def initialize(file_path: nil, srt_content: nil)
|
9
11
|
if file_path && srt_content
|
10
12
|
raise ArgumentError, 'You cannot provide both a file path and an SRT content stream.'
|
@@ -24,6 +26,15 @@ module Appydave
|
|
24
26
|
normalize_lines(content)
|
25
27
|
end
|
26
28
|
|
29
|
+
def write(output_file)
|
30
|
+
File.write(output_file, content)
|
31
|
+
puts "Processed file written to #{output_file}"
|
32
|
+
rescue Errno::EACCES
|
33
|
+
puts "Permission denied: Unable to write to #{output_file}"
|
34
|
+
rescue StandardError => e
|
35
|
+
puts "An error occurred while writing to the file: #{e.message}"
|
36
|
+
end
|
37
|
+
|
27
38
|
private
|
28
39
|
|
29
40
|
def remove_underscores(content)
|
@@ -0,0 +1,327 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Appydave
|
4
|
+
module Tools
|
5
|
+
module SubtitleMaster
|
6
|
+
# Join multiple SRT files into one
|
7
|
+
# - Supports folder, wildcards, sorting via FileResolver
|
8
|
+
class Join
|
9
|
+
# Handles file resolution (folder, wildcards, sorting)
|
10
|
+
class FileResolver
|
11
|
+
def initialize(folder:, files:, sort:)
|
12
|
+
raise ArgumentError, 'folder is required' if folder.nil?
|
13
|
+
raise ArgumentError, 'files is required' if files.nil?
|
14
|
+
|
15
|
+
@folder = folder
|
16
|
+
@files = files
|
17
|
+
@sort = sort
|
18
|
+
end
|
19
|
+
|
20
|
+
def process
|
21
|
+
# Check if folder exists before processing
|
22
|
+
raise Errno::ENOENT, "No such directory - #{@folder}" unless Dir.exist?(@folder)
|
23
|
+
|
24
|
+
file_patterns = @files.split(',').map(&:strip)
|
25
|
+
resolved_files = file_patterns.flat_map { |pattern| resolve_pattern(pattern) }
|
26
|
+
sort_files(resolved_files)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def resolve_pattern(pattern)
|
32
|
+
if pattern.include?('*')
|
33
|
+
Dir.glob(File.join(@folder, pattern))
|
34
|
+
else
|
35
|
+
file_path = File.join(@folder, pattern)
|
36
|
+
File.exist?(file_path) ? [file_path] : []
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def sort_files(files)
|
41
|
+
case @sort
|
42
|
+
when 'asc'
|
43
|
+
files.sort
|
44
|
+
when 'desc'
|
45
|
+
files.sort.reverse
|
46
|
+
else # 'inferred'
|
47
|
+
# If explicit files were provided (no wildcards), maintain order
|
48
|
+
return files unless @files.include?('*')
|
49
|
+
|
50
|
+
files.sort
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Parses SRT files into structured subtitle objects
|
56
|
+
class SRTParser
|
57
|
+
# Represents a single subtitle entry
|
58
|
+
class Subtitle
|
59
|
+
attr_reader :index, :start_time, :end_time, :text
|
60
|
+
|
61
|
+
def initialize(index:, start_time:, end_time:, text:)
|
62
|
+
@index = index
|
63
|
+
@start_time = parse_timestamp(start_time)
|
64
|
+
@end_time = parse_timestamp(end_time)
|
65
|
+
@text = text.strip
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
# Converts SRT timestamp (00:00:00,000) to seconds (float)
|
71
|
+
def parse_timestamp(timestamp)
|
72
|
+
hours, minutes, seconds_ms = timestamp.split(':')
|
73
|
+
seconds, milliseconds = seconds_ms.split(',')
|
74
|
+
|
75
|
+
(hours.to_i * 3600) +
|
76
|
+
(minutes.to_i * 60) +
|
77
|
+
seconds.to_i +
|
78
|
+
(milliseconds.to_i / 1000.0)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def parse(content)
|
83
|
+
validate_content!(content)
|
84
|
+
|
85
|
+
subtitles = []
|
86
|
+
current_block = { text: [] }
|
87
|
+
|
88
|
+
content.split("\n").each do |line|
|
89
|
+
line = line.strip
|
90
|
+
|
91
|
+
if line.empty?
|
92
|
+
process_block(current_block, subtitles) if current_block[:index]
|
93
|
+
current_block = { text: [] }
|
94
|
+
next
|
95
|
+
end
|
96
|
+
|
97
|
+
if current_block[:index].nil?
|
98
|
+
current_block[:index] = line.to_i
|
99
|
+
elsif current_block[:timestamp].nil? && line.include?(' --> ')
|
100
|
+
start_time, end_time = line.split(' --> ')
|
101
|
+
current_block[:timestamp] = { start: start_time, end: end_time }
|
102
|
+
else
|
103
|
+
current_block[:text] << line
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# Process the last block if it exists
|
108
|
+
process_block(current_block, subtitles) if current_block[:index]
|
109
|
+
|
110
|
+
subtitles
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
def validate_content!(content)
|
116
|
+
raise ArgumentError, 'Content cannot be nil' if content.nil?
|
117
|
+
raise ArgumentError, 'Content cannot be empty' if content.strip.empty?
|
118
|
+
|
119
|
+
# Basic structure validation - should have numbers and timestamps
|
120
|
+
return if content.match?(/\d+\s*\n\d{2}:\d{2}:\d{2},\d{3}\s*-->\s*\d{2}:\d{2}:\d{2},\d{3}/)
|
121
|
+
|
122
|
+
raise ArgumentError, 'Invalid SRT format: missing required timestamp format'
|
123
|
+
end
|
124
|
+
|
125
|
+
def process_block(block, subtitles)
|
126
|
+
return unless block[:index] && block[:timestamp] && !block[:text].empty?
|
127
|
+
|
128
|
+
subtitles << Subtitle.new(
|
129
|
+
index: block[:index],
|
130
|
+
start_time: block[:timestamp][:start],
|
131
|
+
end_time: block[:timestamp][:end],
|
132
|
+
text: block[:text].join("\n")
|
133
|
+
)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# Merges multiple subtitle arrays while maintaining timing and adding buffers
|
138
|
+
class SRTMerger
|
139
|
+
def initialize(buffer_ms: 100)
|
140
|
+
@buffer_ms = buffer_ms.to_f
|
141
|
+
end
|
142
|
+
|
143
|
+
def merge(subtitle_arrays)
|
144
|
+
return [] if subtitle_arrays.empty?
|
145
|
+
|
146
|
+
merged = []
|
147
|
+
current_end_time = 0.0
|
148
|
+
|
149
|
+
subtitle_arrays.each do |subtitles|
|
150
|
+
next if subtitles.empty?
|
151
|
+
|
152
|
+
# Calculate offset needed for this batch of subtitles
|
153
|
+
first_subtitle = subtitles.first
|
154
|
+
offset_seconds = calculate_offset(current_end_time, first_subtitle.start_time)
|
155
|
+
|
156
|
+
# Add adjusted subtitles to merged array
|
157
|
+
subtitles.each do |subtitle|
|
158
|
+
adjusted_subtitle = adjust_subtitle_timing(subtitle, offset_seconds)
|
159
|
+
merged << adjusted_subtitle
|
160
|
+
end
|
161
|
+
|
162
|
+
# Update current_end_time for next batch
|
163
|
+
current_end_time = merged.last.end_time
|
164
|
+
end
|
165
|
+
|
166
|
+
# Renumber subtitles sequentially
|
167
|
+
merged.each_with_index do |subtitle, index|
|
168
|
+
subtitle.instance_variable_set(:@index, index + 1)
|
169
|
+
end
|
170
|
+
|
171
|
+
merged
|
172
|
+
end
|
173
|
+
|
174
|
+
private
|
175
|
+
|
176
|
+
def calculate_offset(current_end_time, next_start_time)
|
177
|
+
return 0.0 if current_end_time.zero?
|
178
|
+
|
179
|
+
buffer_seconds = @buffer_ms / 1000.0
|
180
|
+
needed_offset = current_end_time + buffer_seconds - next_start_time
|
181
|
+
[needed_offset, 0].max
|
182
|
+
end
|
183
|
+
|
184
|
+
def adjust_subtitle_timing(subtitle, offset_seconds)
|
185
|
+
# Create new Subtitle instance with adjusted timing
|
186
|
+
SRTParser::Subtitle.new(
|
187
|
+
index: subtitle.index,
|
188
|
+
start_time: format_time(subtitle.start_time + offset_seconds),
|
189
|
+
end_time: format_time(subtitle.end_time + offset_seconds),
|
190
|
+
text: subtitle.text
|
191
|
+
)
|
192
|
+
end
|
193
|
+
|
194
|
+
def format_time(seconds)
|
195
|
+
# Convert seconds back to SRT timestamp format (00:00:00,000)
|
196
|
+
hours = (seconds / 3600).floor
|
197
|
+
minutes = ((seconds % 3600) / 60).floor
|
198
|
+
seconds_remaining = seconds % 60
|
199
|
+
milliseconds = ((seconds_remaining % 1) * 1000).round
|
200
|
+
|
201
|
+
format(
|
202
|
+
'%<hours>02d:%<minutes>02d:%<seconds>02d,%<milliseconds>03d',
|
203
|
+
hours: hours,
|
204
|
+
minutes: minutes,
|
205
|
+
seconds: seconds_remaining.floor,
|
206
|
+
milliseconds: milliseconds
|
207
|
+
)
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
# Converts subtitle objects back to SRT format and writes to disk
|
212
|
+
class SRTWriter
|
213
|
+
def initialize(output_file)
|
214
|
+
@output_file = output_file
|
215
|
+
end
|
216
|
+
|
217
|
+
def write(subtitles)
|
218
|
+
content = format_subtitles(subtitles)
|
219
|
+
File.write(@output_file, content, encoding: 'UTF-8')
|
220
|
+
end
|
221
|
+
|
222
|
+
private
|
223
|
+
|
224
|
+
def format_subtitles(subtitles)
|
225
|
+
subtitles.each_with_index.map do |subtitle, index|
|
226
|
+
[
|
227
|
+
index + 1, # Force sequential numbering
|
228
|
+
format_timestamp_line(subtitle),
|
229
|
+
subtitle.text,
|
230
|
+
'' # Empty line between subtitle blocks
|
231
|
+
].join("\n")
|
232
|
+
end.join("\n")
|
233
|
+
end
|
234
|
+
|
235
|
+
def format_timestamp_line(subtitle)
|
236
|
+
"#{format_timestamp(subtitle.start_time)} --> #{format_timestamp(subtitle.end_time)}"
|
237
|
+
end
|
238
|
+
|
239
|
+
def format_timestamp(seconds)
|
240
|
+
hours = (seconds / 3600).floor
|
241
|
+
minutes = ((seconds % 3600) / 60).floor
|
242
|
+
seconds_remaining = seconds % 60
|
243
|
+
milliseconds = ((seconds_remaining % 1) * 1000).round
|
244
|
+
|
245
|
+
format(
|
246
|
+
'%<hours>02d:%<minutes>02d:%<seconds>02d,%<milliseconds>03d',
|
247
|
+
hours: hours,
|
248
|
+
minutes: minutes,
|
249
|
+
seconds: seconds_remaining.floor,
|
250
|
+
milliseconds: milliseconds
|
251
|
+
)
|
252
|
+
end
|
253
|
+
end
|
254
|
+
|
255
|
+
# Simple logger for debugging
|
256
|
+
class Logger
|
257
|
+
LEVELS = { none: 0, info: 1, detail: 2 }.freeze
|
258
|
+
|
259
|
+
def initialize(level = :info)
|
260
|
+
@level = LEVELS[level] || LEVELS[:info]
|
261
|
+
end
|
262
|
+
|
263
|
+
def log(level, message)
|
264
|
+
puts message if LEVELS[level] <= @level
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
def initialize(folder: './', files: '*.srt', sort: 'inferred', buffer: 100, output: 'merged.srt', log_level: :info)
|
269
|
+
@folder = folder
|
270
|
+
@files = files
|
271
|
+
@sort = sort
|
272
|
+
@buffer = buffer
|
273
|
+
@output = output
|
274
|
+
@logger = Logger.new(log_level)
|
275
|
+
end
|
276
|
+
|
277
|
+
def join
|
278
|
+
@logger.log(:info, "Starting join operation in folder: #{@folder} with files: #{@files}")
|
279
|
+
resolved_files = resolve_files
|
280
|
+
@logger.log(:info, "Resolved files: #{resolved_files.join(', ')}")
|
281
|
+
|
282
|
+
subtitle_groups = parse_files(resolved_files)
|
283
|
+
@logger.log(:detail, "Parsed subtitles: #{subtitle_groups.map(&:size)} from files.")
|
284
|
+
|
285
|
+
merged_subtitles = merge_subtitles(subtitle_groups)
|
286
|
+
@logger.log(:info, "Merged #{subtitle_groups.flatten.size} subtitles into #{merged_subtitles.size} blocks.")
|
287
|
+
|
288
|
+
write_output(merged_subtitles)
|
289
|
+
@logger.log(:info, "Output written to #{@output}")
|
290
|
+
end
|
291
|
+
|
292
|
+
private
|
293
|
+
|
294
|
+
def resolve_files
|
295
|
+
resolver = FileResolver.new(
|
296
|
+
folder: @folder,
|
297
|
+
files: @files,
|
298
|
+
sort: @sort
|
299
|
+
)
|
300
|
+
resolver.process
|
301
|
+
end
|
302
|
+
|
303
|
+
def parse_files(files)
|
304
|
+
files.map do |file|
|
305
|
+
content = File.read(file, encoding: 'UTF-8')
|
306
|
+
parse_srt_content(content)
|
307
|
+
end
|
308
|
+
end
|
309
|
+
|
310
|
+
def parse_srt_content(content)
|
311
|
+
parser = SRTParser.new
|
312
|
+
parser.parse(content)
|
313
|
+
end
|
314
|
+
|
315
|
+
def merge_subtitles(subtitle_groups)
|
316
|
+
merger = SRTMerger.new(buffer_ms: @buffer)
|
317
|
+
merger.merge(subtitle_groups)
|
318
|
+
end
|
319
|
+
|
320
|
+
def write_output(subtitles)
|
321
|
+
writer = SRTWriter.new(@output)
|
322
|
+
writer.write(subtitles)
|
323
|
+
end
|
324
|
+
end
|
325
|
+
end
|
326
|
+
end
|
327
|
+
end
|
data/lib/appydave/tools.rb
CHANGED
@@ -44,18 +44,14 @@ require 'appydave/tools/configuration/configurable'
|
|
44
44
|
require 'appydave/tools/configuration/config'
|
45
45
|
require 'appydave/tools/configuration/models/config_base'
|
46
46
|
require 'appydave/tools/configuration/models/settings_config'
|
47
|
-
# require 'appydave/tools/configuration/models/bank_reconciliation_config'
|
48
47
|
require 'appydave/tools/configuration/models/channels_config'
|
49
48
|
require 'appydave/tools/configuration/models/youtube_automation_config'
|
50
49
|
require 'appydave/tools/name_manager/project_name'
|
51
|
-
# require 'appydave/tools/bank_reconciliation/clean/clean_transactions'
|
52
|
-
# require 'appydave/tools/bank_reconciliation/clean/read_transactions'
|
53
|
-
# require 'appydave/tools/bank_reconciliation/clean/mapper'
|
54
|
-
# require 'appydave/tools/bank_reconciliation/models/transaction'
|
55
50
|
|
56
51
|
require 'appydave/tools/prompt_tools/prompt_completion'
|
57
52
|
|
58
53
|
require 'appydave/tools/subtitle_master/clean'
|
54
|
+
require 'appydave/tools/subtitle_master/join'
|
59
55
|
|
60
56
|
require 'appydave/tools/youtube_automation/gpt_agent'
|
61
57
|
|
data/package-lock.json
CHANGED
@@ -1,12 +1,12 @@
|
|
1
1
|
{
|
2
2
|
"name": "appydave-tools",
|
3
|
-
"version": "0.
|
3
|
+
"version": "0.12.0",
|
4
4
|
"lockfileVersion": 3,
|
5
5
|
"requires": true,
|
6
6
|
"packages": {
|
7
7
|
"": {
|
8
8
|
"name": "appydave-tools",
|
9
|
-
"version": "0.
|
9
|
+
"version": "0.12.0",
|
10
10
|
"devDependencies": {
|
11
11
|
"@klueless-js/semantic-release-rubygem": "github:klueless-js/semantic-release-rubygem",
|
12
12
|
"@semantic-release/changelog": "^6.0.3",
|
data/package.json
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: appydave-tools
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.12.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- David Cruwys
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-12-03 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activemodel
|
@@ -160,8 +160,10 @@ files:
|
|
160
160
|
- bin/configuration.rb
|
161
161
|
- bin/console
|
162
162
|
- bin/gpt_context.rb
|
163
|
+
- bin/move_images.rb
|
163
164
|
- bin/prompt_tools.rb
|
164
165
|
- bin/setup
|
166
|
+
- bin/subtitle_master-old.rb
|
165
167
|
- bin/subtitle_master.rb
|
166
168
|
- bin/youtube_automation.rb
|
167
169
|
- bin/youtube_manager.rb
|
@@ -193,9 +195,11 @@ files:
|
|
193
195
|
- lib/appydave/tools/name_manager/project_name.rb
|
194
196
|
- lib/appydave/tools/prompt_tools/_doc.md
|
195
197
|
- lib/appydave/tools/prompt_tools/prompt_completion.rb
|
198
|
+
- lib/appydave/tools/subtitle_master/_doc-clean.md
|
199
|
+
- lib/appydave/tools/subtitle_master/_doc-join.md
|
196
200
|
- lib/appydave/tools/subtitle_master/_doc-todo.md
|
197
|
-
- lib/appydave/tools/subtitle_master/_doc.md
|
198
201
|
- lib/appydave/tools/subtitle_master/clean.rb
|
202
|
+
- lib/appydave/tools/subtitle_master/join.rb
|
199
203
|
- lib/appydave/tools/types/array_type.rb
|
200
204
|
- lib/appydave/tools/types/base_model.rb
|
201
205
|
- lib/appydave/tools/types/hash_type.rb
|
File without changes
|