592 lines
20 KiB
Plaintext
592 lines
20 KiB
Plaintext
|
|
export script_name = "Merge Scripts"
|
|
export script_description = "Experimental automation for QC merging/exporting"
|
|
export script_version = "0.0.12"
|
|
export script_author = "Myaamori"
|
|
export script_namespace = "myaa.MergeScripts"
|
|
|
|
DependencyControl = require 'l0.DependencyControl'
|
|
depctrl = DependencyControl {
|
|
{
|
|
"json",
|
|
{"myaa.pl", version: "1.6.0", url: "https://github.com/Myaamori/Penlight",
|
|
feed: "https://raw.githubusercontent.com/TypesettingTools/Myaamori-Aegisub-Scripts/master/DependencyControl.json"}
|
|
{"myaa.ASSParser", version: "0.0.4", url: "https://github.com/TypesettingTools/Myaamori-Aegisub-Scripts",
|
|
feed: "https://raw.githubusercontent.com/TypesettingTools/Myaamori-Aegisub-Scripts/master/DependencyControl.json"}
|
|
{"l0.Functional", version: "0.6.0", url: "https://github.com/TypesettingTools/Functional",
|
|
feed: "https://raw.githubusercontent.com/TypesettingTools/Functional/master/DependencyControl.json"}
|
|
}
|
|
}
|
|
|
|
json, pl, parser, F = depctrl\requireModules!
|
|
{:path, :stringx} = pl
|
|
stringx.import!
|
|
|
|
get_data = (line)->
|
|
line.extra and line.extra[script_namespace] and json.decode line.extra[script_namespace]
|
|
|
|
process_imports = (subtitles, selected_lines)->
|
|
selected_lines = selected_lines or [i for i, sub in ipairs subtitles]
|
|
|
|
script_path = aegisub.decode_path("?script")
|
|
|
|
-- keep track of prefixes already used for namespaces to avoid duplicates
|
|
used_prefixes = {}
|
|
for line in *subtitles
|
|
data = get_data line
|
|
if data
|
|
used_prefixes[data.prefix] = true
|
|
|
|
imports = {}
|
|
prefix = 0
|
|
for i in *selected_lines
|
|
line = subtitles[i]
|
|
-- find import definitions among selected lines
|
|
if (line.effect != "import" and line.effect != "import-shifted") or
|
|
(line.extra and line.extra[script_namespace])
|
|
continue
|
|
prefix += 1
|
|
while used_prefixes[prefix]
|
|
prefix += 1
|
|
|
|
-- parse file to import
|
|
file_path = path.join script_path, line.text
|
|
file = io.open file_path
|
|
if not file
|
|
aegisub.log 0, "FATAL: Could not find #{file_path}\n"
|
|
return nil
|
|
|
|
assfile = parser.parse_file file
|
|
file\close!
|
|
|
|
-- bookkeeping (needed for export etc)
|
|
import_metadata = {prefix: prefix, file: file_path, extrakeys: assfile.extradata_mapping}
|
|
|
|
-- increment layer if specified
|
|
for event_line in *assfile.events
|
|
event_line.layer += line.layer
|
|
|
|
if line.effect == "import-shifted"
|
|
-- find sync line in external file and shift lines to match the import line
|
|
sync_line = F.list.find assfile.events, (x)-> x.effect == "sync"
|
|
if not sync_line
|
|
aegisub.log 0, "FATAL: Couldn't find sync line in #{file_path}\n"
|
|
return nil
|
|
|
|
import_metadata.sync_line = sync_line.start_time
|
|
|
|
start_diff = sync_line.start_time - line.start_time
|
|
for event_line in *assfile.events
|
|
event_line.start_time -= start_diff
|
|
event_line.end_time -= start_diff
|
|
|
|
table.insert imports, {:prefix, import_line: i, :assfile, :import_metadata,
|
|
file_name: line.text}
|
|
|
|
return imports
|
|
|
|
find_conflicting_script_info = (subtitles, imports)->
|
|
check_fields = {"PlayResY", "PlayResX", "YCbCr Matrix", "WrapStyle"}
|
|
seen_values = {field, {} for field in *check_fields}
|
|
|
|
-- collate script properties from all imported files
|
|
for imp in *imports
|
|
for field, value in pairs imp.assfile.script_info_mapping
|
|
if not seen_values[field]
|
|
continue
|
|
|
|
seen_values[field][value] = seen_values[field][value] or {}
|
|
table.insert seen_values[field][value], imp.file_name
|
|
|
|
current_script_info = {}
|
|
for i, line in ipairs subtitles
|
|
if line.class == "info"
|
|
current_script_info[line.key] = {index: i, line: line}
|
|
|
|
dialogue_fields = {
|
|
{x: 0, y: 0, height: 1, width: 20, class: "label", label: "Conflicting values found:"}
|
|
}
|
|
conflicting_fields = {}
|
|
-- use values from current script as default values
|
|
confirmed_fields = {field, current_script_info[field].line.value for field in *check_fields}
|
|
i = 1
|
|
for field in *check_fields
|
|
values = [key for key, _ in pairs seen_values[field]]
|
|
if #values > 1
|
|
table.insert conflicting_fields, field
|
|
hint = {}
|
|
for field_value in *values
|
|
for filename in *seen_values[field][field_value]
|
|
table.insert hint, "#{filename}: #{field_value}"
|
|
hint = table.concat hint, "\n"
|
|
|
|
table.insert dialogue_fields, {
|
|
class: "label", label: field, x: 0, y: i, height: 1, width: 10
|
|
}
|
|
table.insert dialogue_fields, {
|
|
class: "dropdown", items: values, value: values[1],
|
|
x: 10, y: i, height: 1, width: 10, hint: hint, name: field
|
|
}
|
|
i += 1
|
|
elseif #values == 1
|
|
confirmed_fields[field] = values[1]
|
|
-- else: no values found in external scripts, keep current value
|
|
|
|
if #conflicting_fields > 0
|
|
button, result = aegisub.dialog.display dialogue_fields
|
|
if not button
|
|
return false
|
|
for field in *conflicting_fields
|
|
confirmed_fields[field] = result[field]
|
|
|
|
for field, field_value in pairs confirmed_fields
|
|
{:index, :line} = current_script_info[field]
|
|
line.value = field_value
|
|
subtitles[index] = line
|
|
|
|
return true
|
|
|
|
add_imports = (subtitles, imports)->
|
|
_, first_dialogue = F.list.find subtitles, (x)-> x.class == "dialogue"
|
|
|
|
-- keep track of how many lines have been added to ensure they're
|
|
-- inserted in the right place
|
|
offset = 0
|
|
style_offset = 0
|
|
-- insert external lines
|
|
for imp in *imports
|
|
-- add extra data
|
|
import_line_pos = imp.import_line + offset
|
|
import_line = subtitles[import_line_pos]
|
|
import_line.extra[script_namespace] = json.encode imp.import_metadata
|
|
subtitles[import_line_pos] = import_line
|
|
|
|
for style in *imp.assfile.styles
|
|
style.name = "#{imp.prefix}$" .. style.name
|
|
-- insert style before the first dialogue line
|
|
subtitles.insert first_dialogue + style_offset, style
|
|
offset += 1
|
|
style_offset += 1
|
|
|
|
for event in *imp.assfile.events
|
|
event.style = "#{imp.prefix}$" .. event.style
|
|
-- insert line below the imp definition
|
|
subtitles.insert imp.import_line + offset + 1, event
|
|
offset += 1
|
|
|
|
merge = (subtitles, selected_lines)->
|
|
imports = process_imports subtitles, selected_lines
|
|
if not imports
|
|
return false
|
|
|
|
-- don't set script info if no imports found
|
|
if #imports == 0
|
|
return true
|
|
|
|
if not find_conflicting_script_info subtitles, imports
|
|
return false
|
|
|
|
add_imports subtitles, imports
|
|
return true
|
|
|
|
|
|
clear_merged = (subtitles, selected_lines)->
|
|
prefixes_to_clear = {}
|
|
selected_lines = selected_lines or [i for i, line in ipairs subtitles]
|
|
|
|
-- determine what files to remove based on selection
|
|
for i in *selected_lines
|
|
line = subtitles[i]
|
|
if line.class != "dialogue"
|
|
continue
|
|
|
|
data = get_data line
|
|
if data
|
|
prefixes_to_clear[data.prefix] = true
|
|
continue
|
|
|
|
{prefix, style} = F.string.split line.style, "$", 1, true, 1
|
|
if style and tonumber(prefix) != nil
|
|
prefixes_to_clear[tonumber prefix] = true
|
|
|
|
-- delete lines corresponding to the namespaces to remove
|
|
lines_to_delete = {}
|
|
for i, line in ipairs subtitles
|
|
if line.class == "style"
|
|
{prefix, style} = F.string.split line.name, "$", 1, true, 1
|
|
prefix = tonumber prefix
|
|
if style and prefix != nil and prefixes_to_clear[prefix]
|
|
table.insert lines_to_delete, i
|
|
elseif line.class == "dialogue"
|
|
-- clear extradata on import lines but don't remove them
|
|
data = get_data line
|
|
if data and prefixes_to_clear[data.prefix]
|
|
line.extra[script_namespace] = nil
|
|
subtitles[i] = line
|
|
continue
|
|
|
|
{prefix, style} = F.string.split line.style, "$", 1, true, 1
|
|
prefix = tonumber prefix
|
|
if style and prefix != nil and prefixes_to_clear[prefix]
|
|
table.insert lines_to_delete, i
|
|
|
|
subtitles.delete lines_to_delete
|
|
|
|
find_import_definitions = (subtitles, predicate=->true)->
|
|
import_lines = {}
|
|
for i, line in ipairs subtitles
|
|
if line.class == "dialogue" and
|
|
(line.effect == "import" or line.effect == "import-shifted") and
|
|
predicate line
|
|
table.insert import_lines, i
|
|
return import_lines
|
|
|
|
import_gui = (subtitles, selected_lines, active_line)->
|
|
dialog = {
|
|
{
|
|
class: "label", x: 0, y: 0
|
|
label: "Select files to import. Files not selected will be unimported if already imported."
|
|
}
|
|
}
|
|
y = 1
|
|
for i in *find_import_definitions subtitles
|
|
line = subtitles[i]
|
|
data = get_data line
|
|
table.insert dialog, {
|
|
class: "checkbox", x: 0, y: y
|
|
value: data != nil, label: line.text
|
|
name: tostring(i)
|
|
}
|
|
y += 1
|
|
|
|
button, result = aegisub.dialog.display dialog
|
|
if not button
|
|
return
|
|
|
|
to_import = [tonumber i for i, val in pairs result when val]
|
|
table.sort to_import
|
|
|
|
-- add temporary extradata to imports to remove
|
|
-- to make it easier to find the lines again post merge
|
|
for i, val in pairs result
|
|
if not val
|
|
j = tonumber i
|
|
line = subtitles[j]
|
|
line.extra._TMP_MERGESCRIPTS = ""
|
|
subtitles[j] = line
|
|
|
|
merge_success = merge subtitles, to_import
|
|
|
|
-- remove temporary extradata regardless of whether merge succeeded
|
|
to_remove = find_import_definitions subtitles, (line)->line.extra._TMP_MERGESCRIPTS != nil
|
|
for i in *to_remove
|
|
line = subtitles[i]
|
|
line.extra._TMP_MERGESCRIPTS = nil
|
|
subtitles[i] = line
|
|
|
|
if not merge_success
|
|
return
|
|
|
|
clear_merged subtitles, to_remove
|
|
|
|
|
|
prompt = (text)->
|
|
aegisub.dialog.display({{class: "textbox", text: text, height: 20, width: 40}})
|
|
|
|
_count_unique = (l)->
|
|
counter = 0
|
|
unique_vals = {}
|
|
for i in *l
|
|
if not unique_vals[i]
|
|
unique_vals[i] = true
|
|
counter += 1
|
|
return counter
|
|
|
|
generate_release = (subtitles, selected_lines, active_line)->
|
|
-- collect lines per class
|
|
lines = {info: {}, style: {}, dialogue: {}}
|
|
files = {}
|
|
for line in *subtitles
|
|
-- find the source files for each namespace for error reporting
|
|
data = get_data line
|
|
if data
|
|
files[data.prefix] = line.text
|
|
continue
|
|
|
|
-- don't include comments or empty lines
|
|
if line.class == "dialogue" and (line.comment or #line.text == 0)
|
|
continue
|
|
|
|
if lines[line.class]
|
|
table.insert lines[line.class], line
|
|
|
|
-- find what styles are actually used by dialogue lines
|
|
used_styles = {line.style, true for line in *lines.dialogue}
|
|
|
|
-- remove namespace from styles and detect clashing styles
|
|
filtered_styles = {}
|
|
added_styles = {}
|
|
clashing_styles = false
|
|
for style in *lines.style
|
|
if not used_styles[style.name]
|
|
continue
|
|
|
|
{prefix, orig_name} = F.string.split style.name, "$", 1, true, 1
|
|
if orig_name
|
|
style.source_file = files[tonumber prefix]
|
|
style.name = orig_name
|
|
else
|
|
style.source_file = "[current file]"
|
|
|
|
added_styles[style.name] = added_styles[style.name] or {}
|
|
table.insert added_styles[style.name], style
|
|
if #added_styles[style.name] >= 2 and
|
|
parser.line_to_raw(added_styles[style.name][1]) != parser.line_to_raw(style)
|
|
clashing_styles = true
|
|
continue
|
|
|
|
table.insert filtered_styles, style
|
|
|
|
lines.style = filtered_styles
|
|
|
|
-- remove namespace from dialogue lines
|
|
for event in *lines.dialogue
|
|
{file, orig_style} = F.string.split event.style, "$", 1, true, 1
|
|
if orig_style
|
|
event.style = orig_style
|
|
|
|
if clashing_styles
|
|
text = "Found clashing styles:\n\n"
|
|
|
|
for style_name, styles in pairs added_styles
|
|
if _count_unique([parser.line_to_raw style for style in *styles]) < 2
|
|
continue
|
|
|
|
first_style = parser.line_to_raw styles[1]
|
|
text ..= "Styles with the name '#{style_name}' appear in multiple files " ..
|
|
"with different definitions:\n"
|
|
for style in *styles
|
|
text ..= "- " .. style.source_file
|
|
text ..= " (clashes; ignored)" if parser.line_to_raw(style) != first_style
|
|
text ..= "\n"
|
|
text ..= "\n"
|
|
|
|
|
|
text ..= "Continue?"
|
|
|
|
if not prompt text
|
|
return
|
|
|
|
script_path = aegisub.decode_path("?script") .. "/"
|
|
script_path = "" if script_path == "?script/"
|
|
out_file_name = aegisub.dialog.save "Save release candidate", "", script_path, "*.ass"
|
|
if not out_file_name
|
|
return
|
|
|
|
file, error = io.open(out_file_name, 'w')
|
|
if not file
|
|
aegisub.log 0, "FATAL: Could not open #{out_file_name} for writing: #{error}\n"
|
|
return
|
|
|
|
|
|
parser.generate_file lines.info, nil, lines.style, lines.dialogue, nil, (line) ->
|
|
file\write line
|
|
|
|
file\close!
|
|
|
|
get_imported_lines = (subtitles)->
|
|
imports = {}
|
|
lines = {}
|
|
-- find lines to export
|
|
for line in *subtitles
|
|
if line.class == "style" or line.class == "dialogue"
|
|
local prefix, style
|
|
if line.class == "style"
|
|
{prefix, style} = F.string.split line.name, "$", 1, true, 1
|
|
line.name = style
|
|
elseif line.class == "dialogue"
|
|
data = get_data line
|
|
if data
|
|
table.insert imports, line
|
|
continue
|
|
|
|
{prefix, style} = F.string.split line.style, "$", 1, true, 1
|
|
line.style = style
|
|
|
|
-- current line should not be exported
|
|
if not style
|
|
continue
|
|
|
|
prefix = tonumber prefix
|
|
lines[prefix] = lines[prefix] or {style: {}, dialogue: {}}
|
|
table.insert lines[prefix][line.class], line
|
|
|
|
return imports, lines
|
|
|
|
export_changes = (subtitles, selected_lines, active_line)->
|
|
imports, lines = get_imported_lines subtitles
|
|
script_path = aegisub.decode_path "?script"
|
|
outputs = {}
|
|
|
|
-- find import definition lines and construct the corresponding output files
|
|
for imp in *imports
|
|
data = get_data imp
|
|
if not data
|
|
continue
|
|
|
|
file = io.open data.file
|
|
if not file
|
|
aegisub.log 1, "ERROR: Could not find #{data.file}, will not export this file.\n"
|
|
continue
|
|
|
|
header_text = {}
|
|
|
|
-- keep all lines before the styles section as is
|
|
for row in file\lines!
|
|
row = F.string.trim row
|
|
if row == "[V4+ Styles]"
|
|
break
|
|
|
|
table.insert header_text, "#{row}\n"
|
|
file\close!
|
|
|
|
imported_lines = lines[data.prefix] or {style: {}, dialogue: {}}
|
|
|
|
-- decrement layers back to original layer
|
|
for line in *imported_lines.dialogue
|
|
line.layer = math.max(line.layer - imp.layer, 0)
|
|
|
|
-- shift back timings for import-shifted lines
|
|
if data.sync_line
|
|
sync_diff = imp.start_time - data.sync_line
|
|
for line in *imported_lines.dialogue
|
|
line.start_time = math.max(line.start_time - sync_diff, 0)
|
|
line.end_time = math.max(line.end_time - sync_diff, 0)
|
|
|
|
outputs[data.file] = {header: table.concat(header_text), lines: imported_lines, extra: data.extrakeys}
|
|
|
|
text = "Do you really wish to overwrite the below files?\n\n"
|
|
text ..= table.concat [fname for fname, output in pairs outputs], "\n"
|
|
if not prompt text
|
|
return
|
|
|
|
-- write to files
|
|
for fname, output in pairs outputs
|
|
file = io.open fname, 'w'
|
|
|
|
file\write output.header
|
|
parser.generate_file nil, nil, output.lines.style,
|
|
output.lines.dialogue, output.extra, (line) ->
|
|
file\write line
|
|
|
|
file\close!
|
|
|
|
script_is_saved = (subtitles, selected_lines, active_line)->
|
|
aegisub.decode_path("?script") != "?script"
|
|
|
|
relpath = (f, script_path)->
|
|
rpath = path.relpath f, script_path
|
|
|
|
if not path.is_windows
|
|
return rpath
|
|
|
|
-- recover original capitalization by replacing the end of
|
|
-- the case normalized path with the corresponding part from the
|
|
-- original f
|
|
f = path.normpath f
|
|
local k
|
|
k = 0
|
|
for i=1,math.min(#f, #rpath)
|
|
ri = #rpath - i + 1
|
|
fi = #f - i + 1
|
|
if rpath\sub(ri, ri) != f\sub(fi, fi)\lower!
|
|
break
|
|
k = i
|
|
|
|
rpath = (rpath\sub 1, #rpath - k) .. (f\sub #f - k + 1, #f)
|
|
return rpath\gsub '\\', '/'
|
|
|
|
|
|
include_file = (subtitles, effect)->
|
|
script_path = aegisub.decode_path("?script") .. "/"
|
|
file_names = aegisub.dialog.open "Choose file to include", "", script_path, "*.ass", true
|
|
|
|
if file_names
|
|
for f in *file_names
|
|
line = parser.create_dialogue_line
|
|
effect: effect, text: relpath(f, script_path), comment: true
|
|
|
|
if effect == "import-shifted"
|
|
vidpos = aegisub.ms_from_frame aegisub.project_properties!.video_position
|
|
line.start_time = vidpos
|
|
line.end_time = vidpos
|
|
|
|
subtitles.append line
|
|
|
|
add_sync_line = (subtitles, selected_lines, active_line)->
|
|
vidpos = aegisub.ms_from_frame aegisub.project_properties!.video_position
|
|
line = parser.create_dialogue_line
|
|
effect: "sync", comment: true,
|
|
start_time: vidpos, end_time: vidpos
|
|
|
|
subtitles.insert active_line, line
|
|
return {active_line}, active_line
|
|
|
|
depctrl\registerMacros {
|
|
{
|
|
"Add file to include",
|
|
"Adds an import definition signifying an external file to be imported",
|
|
(subtitles) -> include_file(subtitles, "import"),
|
|
script_is_saved
|
|
},
|
|
{
|
|
"Add file to include (shifted)",
|
|
"Adds an import definition signifying an external file to be imported and shifted based on the synchronization line",
|
|
(subtitles) -> include_file(subtitles, "import-shifted"),
|
|
script_is_saved
|
|
},
|
|
{
|
|
"Add synchronization line for shifted imports",
|
|
"Adds a synchronization line at the current video time, for use with shifted imports",
|
|
add_sync_line
|
|
},
|
|
{
|
|
"Import all external files",
|
|
"Import lines from external files corresponding to all import definitions in this file",
|
|
(subtitles, selected_lines) -> merge(subtitles, nil),
|
|
script_is_saved
|
|
},
|
|
{
|
|
"Import selected external file(s)",
|
|
"Import lines from external files corresponding to the selected import definitions",
|
|
(subtitles, selected_lines) -> merge(subtitles, selected_lines),
|
|
script_is_saved
|
|
},
|
|
{
|
|
"Remove all external files",
|
|
"Remove all lines in the file that were imported from external files",
|
|
(subtitles, selected_lines) -> clear_merged(subtitles, nil)
|
|
},
|
|
{
|
|
"Remove selected external file(s)",
|
|
"Remove the lines in the file that were imported from external files corresponding to the selected lines",
|
|
(subtitles, selected_lines) -> clear_merged(subtitles, selected_lines)
|
|
},
|
|
{
|
|
"Select files to import or unimport",
|
|
"GUI interface for quick importing and unimporting of files",
|
|
import_gui,
|
|
script_is_saved
|
|
},
|
|
{
|
|
"Generate release candidate",
|
|
"Removes comments and style namespaces",
|
|
generate_release
|
|
},
|
|
{
|
|
"Export changes",
|
|
"Export changes to imported lines to source files",
|
|
export_changes
|
|
}
|
|
}
|