Files
2025-04-12 15:59:13 -04:00

594 lines
21 KiB
C++

#include "subtitle_editor.h"
#include <regex>
#include <string_view>
#include "common/serialization/text/text_ser.h"
#include "common/util/FileUtil.h"
#include "common/util/json_util.h"
#include "common/util/string_util.h"
#include "game/runtime.h"
#include "fmt/format.h"
#include "third-party/imgui/imgui.h"
#include "third-party/imgui/imgui_stdlib.h"
SubtitleEditor::SubtitleEditor() {
m_filter_cutscenes = m_filter_placeholder;
m_filter_non_cutscenes = m_filter_placeholder;
if (g_game_version == GameVersion::Jak1) {
m_subtitle_version = GameSubtitleDB::SubtitleFormat::V1;
} else {
m_subtitle_version = GameSubtitleDB::SubtitleFormat::V2;
}
}
bool SubtitleEditor::is_v1_format() {
return m_subtitle_db.m_subtitle_version == GameSubtitleDB::SubtitleFormat::V1;
}
bool SubtitleEditor::is_scene_in_current_lang(const std::string& scene_name) {
return m_subtitle_db.m_banks.at(m_current_language)->m_scenes.find(scene_name) !=
m_subtitle_db.m_banks.at(m_current_language)->m_scenes.end();
}
void SubtitleEditor::draw_window() {
ImGui::Begin("Subtitle Editor");
// Lazily load the first time the window is displayed
if (!m_db_loaded && !m_db_failed_to_load) {
if (g_game_version == GameVersion::Jak1) {
m_jak1_editor_db.update();
}
m_subtitle_db = load_subtitle_project(m_subtitle_version, g_game_version);
if (m_subtitle_db.m_load_error) {
m_db_failed_to_load = true;
} else {
m_db_loaded = true;
}
} else if (m_db_failed_to_load) {
ImGui::PushStyleColor(ImGuiCol_Text, m_error_text_color);
ImGui::Text("%s",
fmt::format("Error Loading - {}!", m_subtitle_db.m_load_error.value()).c_str());
ImGui::PopStyleColor();
if (ImGui::Button("Try Again")) {
if (g_game_version == GameVersion::Jak1) {
m_jak1_editor_db.update();
}
m_subtitle_db = load_subtitle_project(m_subtitle_version, g_game_version);
if (m_subtitle_db.m_load_error) {
m_db_failed_to_load = true;
} else {
m_db_loaded = true;
}
}
}
if (ImGui::Button("Save Changes")) {
m_files_saved_successfully =
std::make_optional(m_subtitle_db.write_subtitle_db_to_files(g_game_version));
m_repl.rebuild_text();
// TODO - reloading the project would be a good idea because then cutscens that have since been
// modified would appear as such but that creates race conditions when the GUI is parsing at the
// same time it seems so, disabled for now Same Below
// m_subtitle_db = load_subtitle_project(m_subtitle_version, g_game_version);
}
if (m_files_saved_successfully.has_value()) {
ImGui::SameLine();
if (m_files_saved_successfully.value()) {
ImGui::PushStyleColor(ImGuiCol_Text, m_success_text_color);
ImGui::Text("Saved!");
ImGui::PopStyleColor();
} else {
ImGui::PushStyleColor(ImGuiCol_Text, m_error_text_color);
ImGui::Text("Error!");
ImGui::PopStyleColor();
}
}
/*if (ImGui::Button("Reload Project")) {
m_subtitle_db = load_subtitle_project(m_subtitle_version, g_game_version);
}*/
draw_edit_options();
draw_repl_options();
draw_speaker_options();
if (!m_current_scene) {
ImGui::PushStyleColor(ImGuiCol_Text, m_disabled_text_color);
} else {
ImGui::PushStyleColor(ImGuiCol_Text, m_selected_text_color);
}
if (ImGui::TreeNode(
"current_scene", "%s",
m_current_scene
? fmt::format("Currently Selected Scene - {}", m_current_scene->m_name).c_str()
: "Currently Selected Scene")) {
ImGui::PopStyleColor();
if (m_current_scene) {
draw_subtitle_options(*m_current_scene, true);
} else {
ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 0, 0, 255));
ImGui::Text("Select a Scene from Below!");
ImGui::PopStyleColor();
}
ImGui::TreePop();
} else {
ImGui::PopStyleColor();
}
if (ImGui::TreeNode("Cutscenes")) {
draw_scene_section_header(false);
ImGui::InputText("Filter", &m_filter_cutscenes,
ImGuiInputTextFlags_::ImGuiInputTextFlags_AutoSelectAll);
if (m_subtitle_db.m_banks[m_current_language]->m_file_base_path) {
draw_all_cutscenes(true);
}
draw_all_cutscenes(false);
ImGui::TreePop();
}
if (ImGui::TreeNode("Non-Cutscenes")) {
draw_scene_section_header(true);
ImGui::InputText("Filter", &m_filter_non_cutscenes,
ImGuiInputTextFlags_::ImGuiInputTextFlags_AutoSelectAll);
if (m_subtitle_db.m_banks[m_current_language]->m_file_base_path) {
draw_all_non_cutscenes(true);
}
draw_all_non_cutscenes(false);
ImGui::TreePop();
}
ImGui::End();
}
void SubtitleEditor::draw_edit_options() {
if (ImGui::TreeNode("Editing Options")) {
if (ImGui::BeginCombo(
"Editing Language ID",
fmt::format("[{}] {}", m_subtitle_db.m_banks[m_current_language]->m_lang_id,
m_subtitle_db.m_banks[m_current_language]->m_file_path)
.c_str())) {
for (const auto& [key, value] : m_subtitle_db.m_banks) {
const bool isSelected = m_current_language == key;
if (ImGui::Selectable(fmt::format("[{}] {}", value->m_lang_id, value->m_file_path).c_str(),
isSelected)) {
if (key != m_current_language) {
// different language. get rid of current scene as it will be for the wrong language.
m_current_scene = nullptr;
}
m_current_language = key;
}
if (isSelected) {
ImGui::SetItemDefaultFocus();
}
}
ImGui::EndCombo();
}
if (m_subtitle_db.m_banks.find(m_current_language) != m_subtitle_db.m_banks.end()) {
if (m_subtitle_db.m_banks.at(m_current_language)->m_file_base_path) {
ImGui::Text("Language Base - %s",
m_subtitle_db.m_banks.at(m_current_language)->m_file_base_path.value().c_str());
} else {
ImGui::Text("This language has no base language!");
}
}
ImGui::Checkbox("Truncate line summaries", &m_truncate_summaries);
if (g_game_version == GameVersion::Jak1) {
if (ImGui::Button("Update Editor DB")) {
m_jak1_editor_db.update();
}
}
ImGui::TreePop();
}
}
void SubtitleEditor::draw_repl_options() {
if (ImGui::TreeNode("REPL Options")) {
ImGui::TextWrapped(
"This tool requires a REPL connected to the game, with the game built. Run the following "
"to do so:");
ImGui::PushStyleColor(ImGuiCol_Text, m_selected_text_color);
ImGui::Text(" task repl");
ImGui::Text(" (lt)");
ImGui::Text(" (mi)");
ImGui::PopStyleColor();
if (m_repl.is_connected()) {
ImGui::PushStyleColor(ImGuiCol_Text, m_success_text_color);
ImGui::Text("REPL Connected, should be good to go!");
ImGui::PopStyleColor();
} else {
if (ImGui::Button("Connect to REPL on Port 8181")) {
m_repl.connect();
if (!m_repl.is_connected()) {
ImGui::PushStyleColor(ImGuiCol_Text, m_error_text_color);
ImGui::Text("Could not connect.");
ImGui::PopStyleColor();
}
}
}
ImGui::TreePop();
}
}
void SubtitleEditor::draw_speaker_options() {
if (ImGui::TreeNode("Speakers")) {
const auto& bank = m_subtitle_db.m_banks[m_current_language];
for (auto& [speaker_id, speaker_localized] : bank->m_speakers) {
if (speaker_id == "none") {
// this is a special speaker that has ID zero and does not appear in-game
// it means there was no speaker for the line. (e.g. text-only, not vocal)
continue;
}
// Insertion or deletion not needed here as it has to be wired up in .gc and C++ code
// nothing would get persisted and there has to be a translation for all speakers (even if
// it's no translation at all)
ImGui::InputText(speaker_id.c_str(), &speaker_localized);
}
ImGui::TreePop();
}
}
void SubtitleEditor::draw_scene_section_header(const bool non_cutscenes) {
if (ImGui::TreeNode("Create New Scene Entry")) {
ImGui::InputText("New Scene Name", &m_new_scene_name);
if (non_cutscenes && g_game_version == GameVersion::Jak1) {
ImGui::InputText("New Scene ID (hex)", &m_new_scene_id);
}
if (is_scene_in_current_lang(m_new_scene_name)) {
ImGui::PushStyleColor(ImGuiCol_Text, m_error_text_color);
ImGui::Text("Scene already exists with that name, no!");
ImGui::PopStyleColor();
} else if (!m_new_scene_name.empty()) {
if (ImGui::Button("Add Scene")) {
GameSubtitleSceneInfo new_scene;
new_scene.is_cutscene = !non_cutscenes;
if (non_cutscenes && g_game_version == GameVersion::Jak1) {
new_scene.m_hint_id = strtoul(m_new_scene_id.c_str(), nullptr, 16);
} else {
new_scene.m_hint_id = 0;
}
m_subtitle_db.m_banks.at(m_current_language)->m_scenes.emplace(m_new_scene_name, new_scene);
if (m_new_scene_as_current) {
m_current_scene =
&m_subtitle_db.m_banks.at(m_current_language)->m_scenes.at(m_new_scene_name);
}
m_new_scene_name = "";
}
ImGui::SameLine();
ImGui::Checkbox("Set Scene as Current", &m_new_scene_as_current);
}
ImGui::TreePop();
}
}
void SubtitleEditor::draw_scene_node(const bool base_cutscenes,
const std::string& scene_name,
GameSubtitleSceneInfo& scene_info,
std::unordered_set<std::string>& scenes_to_delete) {
bool is_current_scene = m_current_scene && m_current_scene->m_name == scene_name;
bool pop_color = false;
if (!base_cutscenes && is_current_scene) {
ImGui::PushStyleColor(ImGuiCol_Text, m_selected_text_color);
pop_color = true;
} else if (base_cutscenes) {
ImGui::PushStyleColor(ImGuiCol_Text, m_disabled_text_color);
pop_color = true;
} else if (g_game_version == GameVersion::Jak1 && scene_info.is_cutscene &&
m_jak1_editor_db.m_db.find(scene_name) == m_jak1_editor_db.m_db.end()) {
ImGui::PushStyleColor(ImGuiCol_Text, m_warning_color);
pop_color = true;
}
if (ImGui::TreeNode(fmt::format("{}-{}", scene_name, base_cutscenes).c_str(), "%s",
scene_name.c_str())) {
if (pop_color) {
ImGui::PopStyleColor();
}
if (!is_current_scene) {
if (ImGui::Button("Select as Current Cutscene")) {
m_current_scene = &scene_info;
}
}
draw_subtitle_options(scene_info);
ImGui::PushStyleColor(ImGuiCol_Button, m_warning_color);
if (ImGui::Button("Delete")) {
if (scene_info.m_name == m_current_scene->m_name) {
m_current_scene = nullptr;
}
scenes_to_delete.insert(scene_name);
}
ImGui::PopStyleColor();
ImGui::TreePop();
} else if (pop_color) {
ImGui::PopStyleColor();
}
}
void SubtitleEditor::draw_all_cutscenes(bool base_cutscenes) {
std::unordered_set<std::string> scenes_to_delete;
for (auto& [scene_name, scene_info] : m_subtitle_db.m_banks.at(m_current_language)->m_scenes) {
if (!scene_info.is_cutscene || (base_cutscenes && !scene_info.only_defined_in_base) ||
(!base_cutscenes &&
m_subtitle_db.m_banks[m_current_language]->m_file_base_path.has_value() &&
scene_info.only_defined_in_base)) {
continue;
}
if ((!m_filter_cutscenes.empty() && m_filter_cutscenes != m_filter_placeholder) &&
str_util::to_lower(scene_name).find(str_util::to_lower(m_filter_cutscenes)) ==
std::string::npos) {
continue;
}
draw_scene_node(base_cutscenes, scene_name, scene_info, scenes_to_delete);
}
for (auto& scene_name : scenes_to_delete) {
if (m_subtitle_db.m_banks.at(m_current_language)->scene_exists(scene_name)) {
m_subtitle_db.m_banks.at(m_current_language)->m_scenes.erase(scene_name);
}
}
}
void SubtitleEditor::draw_all_non_cutscenes(bool base_cutscenes) {
std::unordered_set<std::string> scenes_to_delete;
for (auto& [scene_name, scene_info] : m_subtitle_db.m_banks.at(m_current_language)->m_scenes) {
if (scene_info.is_cutscene || (base_cutscenes && !scene_info.only_defined_in_base) ||
(!base_cutscenes &&
m_subtitle_db.m_banks[m_current_language]->m_file_base_path.has_value() &&
scene_info.only_defined_in_base)) {
continue;
}
if ((!m_filter_non_cutscenes.empty() && m_filter_non_cutscenes != m_filter_placeholder) &&
str_util::to_lower(scene_name).find(str_util::to_lower(m_filter_non_cutscenes)) ==
std::string::npos) {
continue;
}
draw_scene_node(base_cutscenes, scene_name, scene_info, scenes_to_delete);
}
for (auto& scene_name : scenes_to_delete) {
if (m_subtitle_db.m_banks.at(m_current_language)->scene_exists(scene_name)) {
m_subtitle_db.m_banks.at(m_current_language)->m_scenes.erase(scene_name);
}
}
}
std::string SubtitleEditor::subtitle_line_summary(
const SubtitleLine& line,
const SubtitleLineMetadata& line_meta,
const std::shared_ptr<GameSubtitleBank> /*bank*/) {
// Truncate the text if it's too long, it's supposed to just be a summary at a glance
std::string line_text = "";
if (!line.text.empty()) {
if (m_truncate_summaries && line.text.size() > 30) {
line_text = line.text.substr(0, 27) + "...";
} else {
line_text = line.text;
}
} else {
if (is_v1_format()) {
line_text = "Clear Screen";
} else if (line_meta.merge) {
line_text = "<original game text>";
}
}
// Append important info about the frame / speaker to the front
std::string info_header = fmt::format("[{}", line_meta.frame_start);
// V1
if (is_v1_format()) {
if (line.text.empty()) {
return fmt::format("[{}] {}", line_meta.frame_start, line_text);
} else {
return fmt::format(
"[{}] {}: {}", line_meta.frame_start,
m_subtitle_db.m_banks[m_current_language]->m_speakers.at(line_meta.speaker), line_text);
}
}
// V2
else {
auto speaker_text =
line_meta.speaker == "none"
? ""
: fmt::format("{}: ", m_subtitle_db.m_banks[m_current_language]->m_speakers.at(
line_meta.speaker));
return fmt::format("[{}-{}] {}{}", line_meta.frame_start, line_meta.frame_end, speaker_text,
line_text);
}
}
void SubtitleEditor::draw_subtitle_options(GameSubtitleSceneInfo& scene, bool current_scene) {
if (!m_repl.is_connected()) {
ImGui::PushStyleColor(ImGuiCol_Text, m_error_text_color);
ImGui::Text("REPL not connected, can't play!");
ImGui::PopStyleColor();
} else {
bool play = false;
bool save_and_reload_text = false;
if (ImGui::Button("Save")) {
save_and_reload_text = true;
}
ImGui::SameLine();
if (ImGui::Button("Play")) {
play = true;
}
ImGui::SameLine();
if (ImGui::Button("Save and Play")) {
play = true;
save_and_reload_text = true;
}
if (save_and_reload_text) {
m_subtitle_db.write_subtitle_db_to_files(g_game_version);
m_repl.rebuild_text();
}
if (play) {
if (g_game_version == GameVersion::Jak1) {
m_jak1_editor_db.update();
if (scene.is_cutscene) {
if (m_jak1_editor_db.m_db.find(scene.m_name) == m_jak1_editor_db.m_db.end()) {
lg::error("{} not defined in Jak 1's subtitle editor database!", scene.m_name);
} else {
m_repl.execute_jak1_cutscene_code(m_jak1_editor_db.m_db.at(scene.m_name));
}
} else {
m_repl.play_hint(scene.m_name);
}
} else {
m_repl.play_vag(scene.m_name, scene.is_cutscene);
}
}
}
if (current_scene) {
draw_new_scene_line_form();
}
int i = 0;
for (auto subtitle_line = scene.m_lines.begin(); subtitle_line != scene.m_lines.end();) {
auto& line_text = subtitle_line->text;
auto& line_meta = subtitle_line->metadata;
auto& line_speaker = line_meta.speaker;
int frames[2] = {line_meta.frame_start, line_meta.frame_end};
std::string summary =
subtitle_line_summary(*subtitle_line, line_meta, m_subtitle_db.m_banks[m_current_language]);
if (line_text.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, m_disabled_text_color);
} else if (line_meta.offscreen) {
ImGui::PushStyleColor(ImGuiCol_Text, m_offscreen_text_color);
}
if (ImGui::TreeNode(fmt::format("{}", i).c_str(), "%s", summary.c_str())) {
if (line_text.empty() || line_meta.offscreen) {
ImGui::PopStyleColor();
}
if (is_v1_format()) {
ImGui::InputInt("Starting Frame", &frames[0],
ImGuiInputTextFlags_::ImGuiInputTextFlags_CharsDecimal);
} else {
ImGui::InputInt2("Start and End Frame", frames,
ImGuiInputTextFlags_::ImGuiInputTextFlags_CharsDecimal);
}
if (!line_meta.merge) {
if (ImGui::BeginCombo(
"Speaker",
fmt::format("{} ({})",
m_subtitle_db.m_banks[m_current_language]->m_speakers.at(line_speaker),
line_speaker)
.c_str())) {
for (const auto& [speaker_id, localized_name] :
m_subtitle_db.m_banks[m_current_language]->m_speakers) {
const bool is_selected = speaker_id == line_speaker;
if (is_selected) {
ImGui::SetItemDefaultFocus();
}
if (ImGui::Selectable(fmt::format("{} ({})", localized_name, speaker_id).c_str(),
is_selected)) {
line_meta.speaker = speaker_id;
}
}
ImGui::EndCombo();
}
ImGui::InputText("Text", &line_text, line_meta.merge ? ImGuiInputTextFlags_ReadOnly : 0);
ImGui::Checkbox("Offscreen?", &line_meta.offscreen);
if (!is_v1_format()) {
ImGui::SameLine();
if (ImGui::Checkbox("Merge Text?", &line_meta.merge)) {
// Clear text if they've checked it
if (line_meta.merge) {
line_text = "";
}
}
}
} else {
ImGui::Checkbox("Merge Text?", &line_meta.merge);
}
ImGui::PushStyleColor(ImGuiCol_Button, m_warning_color);
if (ImGui::Button("Remove")) {
subtitle_line = scene.m_lines.erase(subtitle_line);
ImGui::PopStyleColor();
ImGui::TreePop();
continue;
}
ImGui::PopStyleColor();
ImGui::TreePop();
} else if (line_text.empty() || line_meta.offscreen) {
ImGui::PopStyleColor();
}
line_meta.frame_start = frames[0];
line_meta.frame_end = frames[1];
i++;
subtitle_line++;
}
}
void SubtitleEditor::draw_new_scene_line_form() {
if (is_v1_format()) {
ImGui::InputInt("Starting Frame", &m_current_scene_frames[0],
ImGuiInputTextFlags_::ImGuiInputTextFlags_CharsDecimal);
} else {
ImGui::InputInt2("Start and End Frame", m_current_scene_frames,
ImGuiInputTextFlags_::ImGuiInputTextFlags_CharsDecimal);
}
std::string current_speaker = "";
if (m_subtitle_db.m_banks[m_current_language]->m_speakers.find(m_current_scene_speaker) !=
m_subtitle_db.m_banks[m_current_language]->m_speakers.end()) {
current_speaker =
m_subtitle_db.m_banks[m_current_language]->m_speakers.at(m_current_scene_speaker);
}
if (ImGui::BeginCombo("Speaker",
fmt::format("{} ({})", current_speaker, m_current_scene_speaker).c_str())) {
for (const auto& [speaker_id, localized_name] :
m_subtitle_db.m_banks[m_current_language]->m_speakers) {
const bool is_selected = speaker_id == m_current_scene_speaker;
if (is_selected) {
ImGui::SetItemDefaultFocus();
}
if (ImGui::Selectable(fmt::format("{} ({})", localized_name, speaker_id).c_str(),
is_selected)) {
m_current_scene_speaker = speaker_id;
}
}
ImGui::EndCombo();
}
ImGui::InputText("Text", &m_current_scene_text,
m_current_scene_merge ? ImGuiInputTextFlags_ReadOnly : 0);
ImGui::Checkbox("Offscreen", &m_current_scene_offscreen);
if (!is_v1_format()) {
ImGui::SameLine();
if (ImGui::Checkbox("Merge Text?", &m_current_scene_merge)) {
// Clear text if they've checked it
if (m_current_scene_merge) {
m_current_scene_text = "";
}
}
}
// Validation:
// - start frame > 0
// - end frame > start_frame
// - non-empty text
// - pick a speaker
if (m_current_scene_frames[0] < 0 ||
(!is_v1_format() && m_current_scene_frames[1] < m_current_scene_frames[0]) ||
(m_current_scene_text.empty() && !m_current_scene_merge) || m_current_scene_speaker.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, m_error_text_color);
ImGui::Text("Can't add a new text entry with the current fields!");
ImGui::PopStyleColor();
} else {
if (ImGui::Button("Add Text Entry")) {
m_current_scene->add_line(m_current_scene_text, m_current_scene_frames[0],
m_current_scene_frames[1], m_current_scene_offscreen,
m_current_scene_speaker, false);
}
}
if (is_v1_format()) {
if (m_current_scene_frames[0] < 0) {
ImGui::PushStyleColor(ImGuiCol_Text, m_error_text_color);
ImGui::Text("Can't add a clear screen entry with the current fields!");
ImGui::PopStyleColor();
} else {
if (ImGui::Button("Add Clear Screen Entry")) {
m_current_scene->add_line("", m_current_scene_frames[0], 0, m_current_scene_offscreen, "",
false);
}
}
}
}