#include "subtitles_ser.h" #include "common/goos/ParseHelpers.h" #include "common/goos/Reader.h" #include "common/util/FileUtil.h" #include "common/util/json_util.h" #include "third-party/fmt/core.h" int64_t get_int(const goos::Object& obj) { if (obj.is_int()) { return obj.integer_obj.value; } throw std::runtime_error(obj.print() + " was supposed to be an integer, but isn't"); } const goos::Object& car(const goos::Object& x) { if (!x.is_pair()) { throw std::runtime_error("car: invalid pair"); } return x.as_pair()->car; } const goos::Object& cdr(const goos::Object& x) { if (!x.is_pair()) { throw std::runtime_error("cdr: invalid pair"); } return x.as_pair()->cdr; } std::string get_string(const goos::Object& x) { if (x.is_string()) { return x.as_string()->data; } throw std::runtime_error(x.print() + " was supposed to be a string, but isn't"); } /*! * Parse a game text file (GOAL format). * Information is added to the game text database. * * The file should begin with (language-id x y z...) with the given language IDs. * Each entry should be (id "line for 1st language" "line for 2nd language" ...) * This adds the text line to each of the specified languages. */ void parse_text(const goos::Object& data, GameTextDB& db, const GameTextDefinitionFile& /*file_info*/) { const GameTextFontBank* font = nullptr; std::vector> banks; std::string possible_group_name; for_each_in_list(data.as_pair()->cdr, [&](const goos::Object& obj) { if (obj.is_pair()) { auto& head = car(obj); if (head.is_symbol("language-id")) { if (banks.size() != 0) { throw std::runtime_error("Languages have been set multiple times."); } if (cdr(obj).is_empty_list()) { throw std::runtime_error("At least one language must be set."); } if (possible_group_name.empty()) { throw std::runtime_error("Text group must be set before languages."); } for_each_in_list(cdr(obj), [&](const goos::Object& obj) { auto lang = get_int(obj); if (!db.bank_exists(possible_group_name, lang)) { // database has no lang in this group yet banks.push_back(db.add_bank(possible_group_name, std::make_shared(lang))); } else { banks.push_back(db.bank_by_id(possible_group_name, lang)); } }); } else if (head.is_symbol("group-name")) { if (!possible_group_name.empty()) { throw std::runtime_error("group-name has been set multiple times."); } possible_group_name = get_string(car(cdr(obj))); if (possible_group_name.empty()) { throw std::runtime_error("invalid group-name."); } if (!cdr(cdr(obj)).is_empty_list()) { throw std::runtime_error("group-name has too many arguments"); } } else if (head.is_symbol("credits")) { // parse a "credits" object. it's a list of lines where the ID automatically increments, and // empty lines are skipped if (banks.size() == 0) { throw std::runtime_error("At least one language must be set before defining entries."); } if (cdr(obj).is_empty_list() || cdr(cdr(obj)).is_empty_list() || !car(cdr(obj)).is_symbol(":begin") || !car(cdr(cdr(obj))).is_int()) { throw std::runtime_error("Invalid credits begin param"); } const auto& it = cdr(cdr(obj)); int begin_id = car(it).as_int(); int id = begin_id - 1; for_each_in_list(cdr(it), [&](const goos::Object& entry) { ++id; if (entry.is_string()) { if (entry.as_string()->data.empty()) { // empty string! just advance return; } auto line = font->convert_utf8_to_game(entry.as_string()->data); // add to all langs for (auto& bank : banks) { bank->set_line(id, line); } } else if (entry.is_pair()) { int b_i = 0; for_each_in_list(entry, [&](const goos::Object& entry) { if (entry.is_string()) { if (b_i >= int(banks.size())) { throw std::runtime_error(fmt::format("Too many strings in text id #x{:x}", id)); } auto line = font->convert_utf8_to_game(entry.as_string()->data); banks[b_i++]->set_line(id, line); } else { throw std::runtime_error(fmt::format("Non-string value in text id #x{:x}", id)); } }); if (b_i != int(banks.size())) { throw std::runtime_error( fmt::format("Not enough strings specified in text id #x{:x}", id)); } } else { throw std::runtime_error(fmt::format("Non-string value in text id #x{:x}", id)); } }); } else if (head.is_symbol("text-version")) { if (font) { throw std::runtime_error("text version is already set"); } const auto& ver_name = car(cdr(obj)); if (!ver_name.is_symbol()) { throw std::runtime_error("invalid text version entry"); } font = get_font_bank(ver_name.as_symbol()->name); } else if (head.is_int()) { if (!font) { throw std::runtime_error("Text version must be set before defining entries."); } if (banks.size() == 0) { throw std::runtime_error("At least one language must be set before defining entries."); } int i = 0; int id = head.as_int(); for_each_in_list(cdr(obj), [&](const goos::Object& entry) { if (entry.is_string()) { if (i >= int(banks.size())) { throw std::runtime_error(fmt::format("Too many strings in text id #x{:x}", id)); } auto line = font->convert_utf8_to_game(entry.as_string()->data); banks[i++]->set_line(id, line); } else { throw std::runtime_error(fmt::format("Non-string value in text id #x{:x}", id)); } }); if (i != int(banks.size())) { throw std::runtime_error( fmt::format("Not enough strings specified in text id #x{:x}", id)); } } else { throw std::runtime_error("Invalid game text file entry: " + head.print()); } } else { throw std::runtime_error("Invalid game text file"); } }); if (banks.size() == 0) { throw std::runtime_error("At least one language must be set."); } } /*! * Parse a game text file (JSON format). * Information is added to the game text database. * * Each single text entry should follow this format: * : * For example, for (display #x1043) you would add this entry: * "1043": "DISPLAY" * * You can also set multiple sequential lines starting from an initial text_id: * : [, , etc] * For example, Jak 1 credits start at #x0b00, so this would override the first 3 lines: * "0b00": [ * "JAK AND DAXTER: CREDITS LINE 1", * "CREDITS LINE 2", * "CREDITS LINE 3" * ] */ void parse_text_json(const nlohmann::json& json, GameTextDB& db, const GameTextDefinitionFile& file_info) { // Verify we have all data that we need if (!file_info.group_name.has_value()) { throw std::runtime_error( fmt::format("Can't parse {}, did not provide group_name", file_info.file_path)); } if (file_info.language_id == -1) { throw std::runtime_error( fmt::format("Can't parse {}, did not provide language_id", file_info.file_path)); } if (file_info.text_version.empty()) { throw std::runtime_error( fmt::format("Can't parse {}, did not provide text_version", file_info.file_path)); } // Init Settings std::shared_ptr bank; if (!db.bank_exists(file_info.group_name.value(), file_info.language_id)) { // database has no lang in this group yet bank = db.add_bank(file_info.group_name.value(), std::make_shared(file_info.language_id)); } else { bank = db.bank_by_id(file_info.group_name.value(), file_info.language_id); } const GameTextFontBank* font = get_font_bank(file_info.text_version); // Parse the file for (const auto& [text_id, text_value] : json.items()) { auto line_id = std::stoi(text_id, nullptr, 16); if (text_value.is_string()) { // single line replacement auto line = font->convert_utf8_to_game(text_value); // TODO - lint duplicate line definitions across text files bank->set_line(line_id, line); } else if (text_value.is_array()) { // multi-line replacement starting from line_id // (e.g. for Jak 1 credits, start from x0b00) for (const auto& [idx, raw_line] : text_value.items()) { if (!raw_line.is_string()) { throw std::runtime_error(fmt::format( "Non string provided for line {} / text id #x{} of _credits", idx, line_id)); } auto line = font->convert_utf8_to_game(raw_line); bank->set_line(line_id++, line); // increment line_id } } else { // Unexpected value type throw std::runtime_error( fmt::format("Must provide string or array for text id #x{}", text_id)); } } } /*! * Parse a game subtitle file. * Information is added to the game subtitles database. * * The file should begin with (language-id x y z...) for the given language IDs. * Each scene should be (scene-name ... ) * This adds the subtitle to each of the specified languages. */ void parse_subtitle(const goos::Object& data, GameSubtitleDB& db, const std::string& file_path) { const GameTextFontBank* font = nullptr; std::map> banks; for_each_in_list(data.as_pair()->cdr, [&](const goos::Object& obj) { if (obj.is_pair()) { auto& head = car(obj); if (head.is_symbol() && head.as_symbol()->name == "language-id") { if (banks.size() != 0) { throw std::runtime_error("Languages have been set multiple times."); } if (cdr(obj).is_empty_list()) { throw std::runtime_error("At least one language must be set."); } for_each_in_list(cdr(obj), [&](const goos::Object& obj) { auto lang = get_int(obj); if (!db.bank_exists(lang)) { // database has no lang yet banks[lang] = db.add_bank(std::make_shared(lang)); } else { banks[lang] = db.bank_by_id(lang); } }); } else if (head.is_symbol("text-version")) { if (font) { throw std::runtime_error("text version is already set"); } const auto& ver_name = car(cdr(obj)); if (!ver_name.is_symbol()) { throw std::runtime_error("invalid text version entry"); } font = get_font_bank(ver_name.as_symbol()->name); } else if (head.is_string() || head.is_int()) { if (!font) { throw std::runtime_error("Text version must be set before defining entries."); } if (banks.size() == 0) { throw std::runtime_error("At least one language must be set before defining scenes."); } auto kind = SubtitleSceneKind::Movie; int id = 0; auto entries = cdr(obj); if (head.is_int()) { kind = SubtitleSceneKind::Hint; } else if (entries.is_pair() && car(entries).is_symbol()) { const auto& parm = car(entries).as_symbol()->name; if (parm == ":hint") { entries = cdr(entries); id = car(entries).as_int(); kind = SubtitleSceneKind::HintNamed; } else { throw std::runtime_error("Unknown parameter for subtitle scene"); } entries = cdr(entries); } GameSubtitleSceneInfo scene(kind); if (kind == SubtitleSceneKind::Movie || kind == SubtitleSceneKind::HintNamed) { scene.set_name(head.as_string()->data); } else if (kind == SubtitleSceneKind::Hint) { id = head.as_int(); } scene.set_id(id); for_each_in_list(entries, [&](const goos::Object& entry) { if (entry.is_pair()) { // expected formats: // (time ) // all arguments have default values. the arguments are: // "speaker" "line" - two strings. one for the speaker's name and one for the actual // line. speaker can be empty. default is just empty string. // :offscreen - speaker is offscreen. default is not offscreen. if (!car(entry).is_int()) { throw std::runtime_error("Each entry must start with a timestamp (number)"); } auto time = car(entry).as_int(); goos::StringObject *speaker = nullptr, *line = nullptr; bool offscreen = false; if (scene.kind() == SubtitleSceneKind::Hint || scene.kind() == SubtitleSceneKind::HintNamed) { offscreen = true; } for_each_in_list(cdr(entry), [&](const goos::Object& arg) { if (arg.is_string()) { if (!speaker) { speaker = arg.as_string(); } else if (!line) { line = arg.as_string(); } else { throw std::runtime_error("Invalid string in subtitle entry"); } } else if (speaker && !line) { throw std::runtime_error( "Invalid object in subtitle entry, expecting actual line string after speaker"); } else if (arg.is_symbol()) { if (scene.kind() == SubtitleSceneKind::Movie && arg.as_symbol()->name == ":offscreen") { offscreen = true; } else { throw std::runtime_error( fmt::format("Unknown parameter {} in subtitle", arg.as_symbol()->name)); } } }); auto line_str = font->convert_utf8_to_game(line ? line->data : ""); auto speaker_str = font->convert_utf8_to_game(speaker ? speaker->data : ""); scene.add_line(time, line_str, speaker_str, offscreen); } else { throw std::runtime_error( fmt::format("{} | Each entry must be a non-empty list", scene.name())); } }); for (auto& [lang, bank] : banks) { if (!bank->scene_exists(scene.name())) { bank->add_scene(scene); } else { auto& old_scene = bank->scene_by_name(scene.name()); old_scene.from_other_scene(scene); } } } else { throw std::runtime_error("Invalid game subtitles file entry: " + head.print()); } } else { throw std::runtime_error("Invalid game subtitles file"); } }); if (banks.size() == 0) { throw std::runtime_error("At least one language must be set."); } } void parse_subtitle_json(GameSubtitleDB& db, const GameSubtitleDefinitionFile& file_info) { // TODO - some validation // Init Settings std::shared_ptr bank; if (!db.bank_exists(file_info.language_id)) { // database has no lang yet bank = db.add_bank(std::make_shared(file_info.language_id)); } else { bank = db.bank_by_id(file_info.language_id); } bank->m_text_version = file_info.text_version; bank->m_file_path = file_info.lines_path; const GameTextFontBank* font = get_font_bank(file_info.text_version); // Parse the file SubtitleMetadataFile meta_file; SubtitleFile lines_file; try { // If we have a base file defined, load that and merge it if (file_info.meta_base_path) { auto base_data = parse_commented_json(file_util::read_text_file(file_util::get_jak_project_dir() / file_info.meta_base_path.value()), "subtitle_meta_base_path"); auto data = parse_commented_json( file_util::read_text_file(file_util::get_jak_project_dir() / file_info.meta_path), "subtitle_meta_path"); base_data.at("cutscenes").update(data.at("cutscenes")); base_data.at("hints").update(data.at("hints")); meta_file = base_data; } else { meta_file = parse_commented_json( file_util::read_text_file(file_util::get_jak_project_dir() / file_info.meta_path), "subtitle_meta_path"); } if (file_info.lines_base_path) { auto base_data = parse_commented_json(file_util::read_text_file(file_util::get_jak_project_dir() / file_info.lines_base_path.value()), "subtitle_line_base_path"); auto data = parse_commented_json( file_util::read_text_file(file_util::get_jak_project_dir() / file_info.lines_path), "subtitle_line_path"); base_data.at("cutscenes").update(data.at("cutscenes")); base_data.at("hints").update(data.at("hints")); base_data.at("speakers").update(data.at("speakers")); auto test = base_data.dump(); lines_file = base_data; } else { lines_file = parse_commented_json( file_util::read_text_file(file_util::get_jak_project_dir() / file_info.lines_path), "subtitle_line_path"); } } catch (std::exception& e) { lg::error("Unable to parse subtitle json entry, couldn't successfully load files - {}", e.what()); throw; } // Iterate through the metadata file as blank lines are no omitted from the lines file now // Cutscenes First for (const auto& [cutscene_name, cutscene_lines] : meta_file.cutscenes) { GameSubtitleSceneInfo scene(SubtitleSceneKind::Movie); scene.set_name(cutscene_name); scene.m_sorting_group = db.m_subtitle_groups->find_group(cutscene_name); scene.m_sorting_group_idx = db.m_subtitle_groups->find_group_index(scene.m_sorting_group); // Iterate the lines, grab the actual text from the lines file if it's not a clear screen entry int line_idx = 0; int lines_added = 0; for (const auto& line : cutscene_lines) { if (line.clear) { scene.add_clear_entry(line.frame); lines_added++; } else { if (lines_file.speakers.find(line.speaker) == lines_file.speakers.end() || lines_file.cutscenes.find(cutscene_name) == lines_file.cutscenes.end() || lines_file.cutscenes.at(cutscene_name).size() < line_idx) { lg::warn( "{} Couldn't find {} in line file, or line list is too small, or speaker could not " "be resolved {}!", file_info.language_id, cutscene_name, line.speaker); } else { // NOTE - the convert_utf8_to_game function is really really slow (about 80-90% of the // time loading the subtitle files) // TODO - improve that as a follow up sometime in the future scene.add_line( line.frame, font->convert_utf8_to_game(lines_file.cutscenes.at(cutscene_name).at(line_idx)), font->convert_utf8_to_game(lines_file.speakers.at(line.speaker)), line.offscreen); lines_added++; } line_idx++; } } // Verify we added the amount of lines we expected to if (lines_added != cutscene_lines.size()) { throw std::runtime_error( fmt::format("Cutscene: '{}' has a mismatch in metadata lines vs text lines. Expected {} " "only added {} lines", cutscene_name, cutscene_lines.size(), lines_added)); } // TODO - add scene, can't we just use an emplace here? if (!bank->scene_exists(scene.name())) { bank->add_scene(scene); } else { auto& old_scene = bank->scene_by_name(scene.name()); old_scene.from_other_scene(scene); } } // Now hints for (const auto& [hint_name, hint_info] : meta_file.hints) { GameSubtitleSceneInfo scene(SubtitleSceneKind::Hint); scene.set_name(hint_name); /*scene.m_sorting_group = db.m_subtitle_groups->find_group(hint_name); scene.m_sorting_group_idx = db.m_subtitle_groups->find_group_index(scene.m_sorting_group);*/ if (hint_info.id == "0") { scene.m_kind = SubtitleSceneKind::HintNamed; } else { scene.set_id(std::stoi(hint_info.id, nullptr, 16)); } // Iterate the lines, grab the actual text from the lines file if it's not a clear screen entry int line_idx = 0; int lines_added = 0; for (const auto& line : hint_info.lines) { if (line.clear) { scene.add_clear_entry(line.frame); lines_added++; } else { if (lines_file.speakers.find(line.speaker) == lines_file.speakers.end() || lines_file.hints.find(hint_name) == lines_file.hints.end() || lines_file.hints.at(hint_name).size() < line_idx) { lg::warn( "{} Couldn't find {} in line file, or line list is too small, or speaker could not " "be resolved {}!", file_info.language_id, hint_name, line.speaker); } else { // NOTE - the convert_utf8_to_game function is really really slow (about 80-90% of the // time loading the subtitle files) // TODO - improve that as a follow up sometime in the future scene.add_line(line.frame, font->convert_utf8_to_game(lines_file.hints.at(hint_name).at(line_idx)), font->convert_utf8_to_game(lines_file.speakers.at(line.speaker)), true); lines_added++; } line_idx++; } } // Verify we added the amount of lines we expected to if (lines_added != hint_info.lines.size()) { throw std::runtime_error( fmt::format("Hint: '{}' has a mismatch in metadata lines vs text lines. Expected {} " "only added {} lines", hint_name, hint_info.lines.size(), lines_added)); } // TODO - add scene, can't we just use an emplace here? if (!bank->scene_exists(scene.name())) { bank->add_scene(scene); } else { auto& old_scene = bank->scene_by_name(scene.name()); old_scene.from_other_scene(scene); } } } GameTextVersion parse_text_only_version(const std::string& filename) { goos::Reader reader; return parse_text_only_version(reader.read_from_file({filename})); } GameTextVersion parse_text_only_version(const goos::Object& data) { const GameTextFontBank* font = nullptr; for_each_in_list(data.as_pair()->cdr, [&](const goos::Object& obj) { if (obj.is_pair()) { auto& head = car(obj); if (head.is_symbol("text-version")) { if (font) { throw std::runtime_error("text version is already set"); } const auto& ver_name = car(cdr(obj)); if (!ver_name.is_symbol()) { throw std::runtime_error("invalid text version entry"); } font = get_font_bank(ver_name.as_symbol()->name); } } }); if (!font) { throw std::runtime_error("text version not found"); } return font->version(); } void GameSubtitleGroups::hydrate_from_asset_file() { std::string file_path = (file_util::get_jak_project_dir() / "game" / "assets" / "jak1" / "subtitle" / "subtitle-groups.json") .string(); auto config_str = file_util::read_text_file(file_path); auto group_data = parse_commented_json(config_str, file_path); for (const auto& [key, val] : group_data.items()) { try { if (key == group_order_key) { m_group_order = val.get>(); } else { m_groups[key] = val.get>(); } } catch (std::exception& ex) { lg::print("Bad subtitle group entry - {} - {}", key, ex.what()); } } } std::string GameSubtitleGroups::find_group(const std::string& scene_name) { for (auto const& [group, scenes] : m_groups) { for (auto const& name : scenes) { if (name == scene_name) { return group; } } } // Add to the uncategorized group if it wasn't found m_groups[uncategorized_group].push_back(scene_name); return uncategorized_group; } int GameSubtitleGroups::find_group_index(const std::string& group_name) { auto it = find(m_group_order.begin(), m_group_order.end(), group_name); if (it != m_group_order.end()) { return it - m_group_order.begin(); } else { return m_group_order.size() - 1; } } void GameSubtitleGroups::remove_scene(const std::string& group_name, const std::string& scene_name) { if (m_groups.count(group_name) == 0) { lg::error("Subtitle group {} doesn't exist! Abort.", group_name); return; } m_groups[group_name].erase( std::remove(m_groups[group_name].begin(), m_groups[group_name].end(), scene_name), m_groups[group_name].end()); } void GameSubtitleGroups::add_scene(const std::string& group_name, const std::string& scene_name) { std::string group = group_name; if (m_groups.count(group_name) == 0) { lg::error("Subtitle group {} doesn't exist! Add to uncategorized.", group_name); group = uncategorized_group; } auto it = std::find(m_groups[group].begin(), m_groups[group].end(), scene_name); if (it != m_groups[group].end()) { lg::error("Scene {} already exists in group {}", scene_name, group); } else { m_groups[group].push_back(scene_name); } } void open_text_project(const std::string& kind, const std::string& filename, std::vector& text_files) { goos::Reader reader; auto& proj = reader.read_from_file({filename}).as_pair()->cdr.as_pair()->car; if (!proj.is_pair() || !proj.as_pair()->car.is_symbol() || proj.as_pair()->car.as_symbol()->name != kind) { throw std::runtime_error(fmt::format("invalid {} project", kind)); } goos::for_each_in_list(proj.as_pair()->cdr, [&](const goos::Object& o) { if (o.is_pair() && o.as_pair()->cdr.is_pair()) { auto args = o.as_pair(); auto& action = args->car.as_symbol()->name; args = args->cdr.as_pair(); if (action == "file") { auto& file_path = args->car.as_string()->data; auto new_file = GameTextDefinitionFile(); new_file.format = GameTextDefinitionFile::Format::GOAL; new_file.file_path = file_path; text_files.push_back(new_file); } else if (action == "file-json") { auto& language_id = args->car.as_int(); args = args->cdr.as_pair(); auto& text_version = args->car.as_symbol()->name; args = args->cdr.as_pair(); std::optional group_name = std::nullopt; group_name = args->car.as_string()->data; args = args->cdr.as_pair()->car.as_pair(); goos::for_each_in_list(args->cdr.as_pair()->car, [&](const goos::Object& o) { text_files.push_back({GameTextDefinitionFile::Format::JSON, o.as_string()->data, (int)language_id, text_version, group_name}); }); } else { throw std::runtime_error(fmt::format("unknown action {} in {} project", action, kind)); } } else { throw std::runtime_error(fmt::format("invalid entry in {} project", kind)); } }); } void open_subtitle_project(const std::string& kind, const std::string& filename, std::vector& subtitle_files) { goos::Reader reader; auto& proj = reader.read_from_file({filename}).as_pair()->cdr.as_pair()->car; if (!proj.is_pair() || !proj.as_pair()->car.is_symbol() || proj.as_pair()->car.as_symbol()->name != kind) { throw std::runtime_error(fmt::format("invalid {} project", kind)); } goos::for_each_in_list(proj.as_pair()->cdr, [&](const goos::Object& o) { if (o.is_pair() && o.as_pair()->cdr.is_pair()) { auto args = o.as_pair(); auto& action = args->car.as_symbol()->name; args = args->cdr.as_pair(); if (action == "file") { auto& file_path = args->car.as_string()->data; auto new_file = GameSubtitleDefinitionFile(); new_file.format = GameSubtitleDefinitionFile::Format::GOAL; new_file.lines_path = file_path; subtitle_files.push_back(new_file); } else if (action == "file-json") { auto new_file = GameSubtitleDefinitionFile(); new_file.format = GameSubtitleDefinitionFile::Format::JSON; while (true) { const auto& kwarg = args->car.as_symbol()->name; args = args->cdr.as_pair(); if (kwarg == ":language-id") { new_file.language_id = args->car.as_int(); } else if (kwarg == ":text-version") { new_file.text_version = args->car.as_string()->data; } else if (kwarg == ":lines") { new_file.lines_path = args->car.as_string()->data; } else if (kwarg == ":meta") { new_file.meta_path = args->car.as_string()->data; } else if (kwarg == ":lines-base") { new_file.lines_base_path = args->car.as_string()->data; } else if (kwarg == ":meta-base") { new_file.meta_base_path = args->car.as_string()->data; } if (args->cdr.is_empty_list()) { break; } args = args->cdr.as_pair(); } subtitle_files.push_back(new_file); } else { throw std::runtime_error(fmt::format("unknown action {} in {} project", action, kind)); } } else { throw std::runtime_error(fmt::format("invalid entry in {} project", kind)); } }); } void to_json(json& j, const SubtitleCutsceneLineMetadata& obj) { j = json{{"frame", obj.frame}, {"offscreen", obj.offscreen}, {"speaker", obj.speaker}, {"clear", obj.clear}}; } void from_json(const json& j, SubtitleCutsceneLineMetadata& obj) { json_deserialize_if_exists(frame); json_deserialize_if_exists(offscreen); json_deserialize_if_exists(speaker); json_deserialize_if_exists(clear); } void to_json(json& j, const SubtitleHintLineMetadata& obj) { j = json{{"frame", obj.frame}, {"speaker", obj.speaker}, {"clear", obj.clear}}; } void from_json(const json& j, SubtitleHintLineMetadata& obj) { json_deserialize_if_exists(frame); json_deserialize_if_exists(speaker); json_deserialize_if_exists(clear); } void to_json(json& j, const SubtitleHintMetadata& obj) { j = json{{"id", obj.id}, {"lines", obj.lines}}; } void from_json(const json& j, SubtitleHintMetadata& obj) { json_deserialize_if_exists(id); json_deserialize_if_exists(lines); } void to_json(json& j, const SubtitleMetadataFile& obj) { j = json{{"cutscenes", obj.cutscenes}, {"hints", obj.hints}}; } void from_json(const json& j, SubtitleMetadataFile& obj) { json_deserialize_if_exists(cutscenes); json_deserialize_if_exists(hints); } void to_json(json& j, const SubtitleFile& obj) { j = json{{"speakers", obj.speakers}, {"cutscenes", obj.cutscenes}, {"hints", obj.hints}}; } void from_json(const json& j, SubtitleFile& obj) { json_deserialize_if_exists(speakers); json_deserialize_if_exists(cutscenes); json_deserialize_if_exists(hints); } // TODO - temporary code for migration SubtitleMetadataFile dump_bank_as_meta_json( std::shared_ptr bank, std::unordered_map speaker_lookup) { auto meta_file = SubtitleMetadataFile(); auto font = get_font_bank("jak1-v2"); for (const auto& [scene_name, scene_info] : bank->m_scenes) { if (scene_info.m_kind == SubtitleSceneKind::Movie) { std::vector lines; for (const auto& line : scene_info.m_lines) { auto line_meta = SubtitleCutsceneLineMetadata(); line_meta.frame = line.frame; if (line.line.empty()) { line_meta.clear = true; } else { auto line_speaker = font->convert_game_to_utf8(line.speaker.c_str()); for (const auto& [speaker, speaker_localized] : speaker_lookup) { if (line_speaker == speaker_localized) { line_speaker = speaker; } } line_meta.offscreen = line.offscreen; line_meta.speaker = line_speaker; } lines.push_back(line_meta); } meta_file.cutscenes[scene_name] = lines; } else if (scene_info.m_kind == SubtitleSceneKind::Hint || scene_info.m_kind == SubtitleSceneKind::HintNamed) { SubtitleHintMetadata hint; hint.id = fmt::format("{:x}", scene_info.m_id); std::vector lines; for (const auto& line : scene_info.m_lines) { auto line_meta = SubtitleHintLineMetadata(); line_meta.frame = line.frame; if (line.line.empty()) { line_meta.clear = true; } else { auto line_speaker = font->convert_game_to_utf8(line.speaker.c_str()); for (const auto& [speaker, speaker_localized] : speaker_lookup) { if (line_speaker == speaker_localized) { line_speaker = speaker; } } line_meta.speaker = line_speaker; } lines.push_back(line_meta); } hint.lines = lines; meta_file.hints[scene_name] = hint; } } return meta_file; } // TODO - temporary code for migration SubtitleFile dump_bank_as_json(std::shared_ptr bank, std::shared_ptr base_bank, std::unordered_map speaker_lookup) { SubtitleFile file; file.speakers = speaker_lookup; auto font = get_font_bank("jak1-v2"); // Figure out speakers for (const auto& [scene_name, scene_info] : bank->m_scenes) { for (const auto& line : scene_info.m_lines) { if (line.line.empty()) { continue; } auto line_speaker = font->convert_game_to_utf8(line.speaker.c_str()); bool new_speaker = true; for (const auto& [speaker, speaker_localized] : file.speakers) { if (line_speaker == speaker_localized) { new_speaker = false; break; } } if (new_speaker) { // if the speaker is in the english speaker map, append it if (speaker_lookup.find(line_speaker) != speaker_lookup.end()) { file.speakers[line_speaker] = line_speaker; } else { // otherwise, go figure it out manually, most names are the same so this isn't worth // writing code for file.speakers[fmt::format("unknown-{}", scene_info.m_name)] = line_speaker; } } } } // Hints for (const auto& [scene_name, scene_info] : bank->m_scenes) { if (scene_info.m_kind == SubtitleSceneKind::Hint || scene_info.m_kind == SubtitleSceneKind::HintNamed) { // Check if the number of hints in the translated language match that of the base language if (base_bank->m_scenes.find(scene_name) == base_bank->m_scenes.end()) { lg::warn("scene not found in base language - {}:{}", bank->m_lang_id, scene_name); } else { if (scene_info.m_lines.size() > base_bank->m_scenes.at(scene_name).m_lines.size()) { lg::info("hint - translation has more lines than base - {}:{}", bank->m_lang_id, scene_name); } file.hints[scene_name] = {}; for (const auto& scene_line : scene_info.m_lines) { if (scene_line.line.empty()) { continue; } auto line_utf8 = font->convert_game_to_utf8(scene_line.line.c_str()); file.hints[scene_name].push_back(line_utf8); } } } } // Cutscenes for (const auto& [scene_name, scene_info] : bank->m_scenes) { if (scene_info.m_kind == SubtitleSceneKind::Movie) { // Check if the number of hints in the translated language match that of the base language if (base_bank->m_scenes.find(scene_name) == base_bank->m_scenes.end()) { lg::warn("scene not found in base language - {}:{}", bank->m_lang_id, scene_name); } else { if (scene_info.m_lines.size() > base_bank->m_scenes.at(scene_name).m_lines.size()) { lg::info("cutscene - translation has more lines than base - {}:{}", bank->m_lang_id, scene_name); } file.cutscenes[scene_name] = {}; for (const auto& scene_line : scene_info.m_lines) { if (scene_line.line.empty()) { continue; } auto line_utf8 = font->convert_game_to_utf8(scene_line.line.c_str()); file.cutscenes[scene_name].push_back(line_utf8); } } } } return file; } GameSubtitleDB load_subtitle_project(GameVersion game_version) { // Load the subtitle files GameSubtitleDB db; db.m_subtitle_groups = std::make_unique(); db.m_subtitle_groups->hydrate_from_asset_file(); try { goos::Reader reader; std::vector files; std::string subtitle_project = (file_util::get_jak_project_dir() / "game" / "assets" / version_to_game_name(game_version) / "game_subtitle.gp") .string(); open_subtitle_project("subtitle", subtitle_project, files); for (auto& file : files) { if (file.format == GameSubtitleDefinitionFile::Format::GOAL) { auto code = reader.read_from_file({file.lines_path}); parse_subtitle(code, db, file.lines_path); } else if (file.format == GameSubtitleDefinitionFile::Format::JSON) { parse_subtitle_json(db, file); } } } catch (std::runtime_error& e) { lg::error("error loading subtitle project: {}", e.what()); } // Dump new JSON format (uncomment if you need it) // TODO -- TEMPORARY CODE FOR MIGRATION -- REMOVE LATER // auto speaker_json = parse_commented_json( // file_util::read_text_file((file_util::get_jak_project_dir() / "game" / "assets" / // version_to_game_name(game_version) / "subtitle" / // "_speaker_lookup.jsonc")), // "_speaker_lookup.jsonc"); // auto speaker_lookup = // speaker_json // .get>>(); // std::vector locale_lookup = {"en-US", "fr-FR", "de-DE", "es-ES", "it-IT", // "jp-JP", "en-GB", "pt-PT", "fi-FI", "sv-SE", // "da-DK", "no-NO", "nl-NL", "pt-BR", "hu-HU", "ca-ES", // "is-IS"}; // for (const auto& [language_id, bank] : db.m_banks) { // auto meta_file = // dump_bank_as_meta_json(bank, speaker_lookup.at(fmt::format("{}", language_id))); // std::string dump_path = // (file_util::get_jak_project_dir() / "game" / "assets" / // version_to_game_name(game_version) / // "subtitle" / fmt::format("subtitle_meta_{}.json", locale_lookup.at(language_id))) // .string(); // json data = meta_file; // try { // std::string str = data.dump(2); // file_util::write_text_file(dump_path, str); // } catch (std::exception& ex) { // lg::error(ex.what()); // } // // Now dump the actual subtitles // auto subtitle_file = dump_bank_as_json(bank, db.m_banks.at(0), // speaker_lookup.at(fmt::format("{}", language_id))); // dump_path = // (file_util::get_jak_project_dir() / "game" / "assets" / // version_to_game_name(game_version) / // "subtitle" / fmt::format("subtitle_lines_{}.json", locale_lookup.at(language_id))) // .string(); // data = subtitle_file; // try { // std::string str = data.dump(2); // file_util::write_text_file(dump_path, str); // } catch (std::exception& ex) { // lg::error(ex.what()); // } // } return db; }