Franco Zanardi commited on
Commit
6774799
·
1 Parent(s): d083627

feature: allow edit templates

Browse files
.gitignore CHANGED
@@ -1 +1,2 @@
1
  .vscode
 
 
1
  .vscode
2
+ __pycache__
src/app.py CHANGED
@@ -9,11 +9,14 @@ from ui.step2_configure import render_step2
9
  from ui.step3_edit import render_step3
10
  from ui.step4_render import render_step4
11
  from ui.step5_view import render_step5
 
12
 
13
  st.set_page_config(layout="wide", page_title="Pycaps Demo")
14
  logger.set_logging_level(logging.DEBUG)
15
  initialize_session_state()
 
16
 
 
17
  st.title("🎬 Pycaps Demo")
18
  st.markdown("""
19
  <style>
 
9
  from ui.step3_edit import render_step3
10
  from ui.step4_render import render_step4
11
  from ui.step5_view import render_step5
12
+ from file_manager import init_file_manager
13
 
14
  st.set_page_config(layout="wide", page_title="Pycaps Demo")
15
  logger.set_logging_level(logging.DEBUG)
16
  initialize_session_state()
17
+ init_file_manager()
18
 
19
+ # --- Renderizado de la UI ---
20
  st.title("🎬 Pycaps Demo")
21
  st.markdown("""
22
  <style>
src/config.py CHANGED
@@ -1,11 +1,12 @@
1
  import os
2
  import tempfile
3
 
4
- MAX_CONCURRENT_JOBS = 3
5
  LOCK_DIR = os.path.join(tempfile.gettempdir(), "pycaps_locks")
6
  os.makedirs(LOCK_DIR, exist_ok=True)
7
  MAX_VIDEO_SIZE = 50 * 1024 * 1024
8
  LOCK_TTL_SECONDS = 20 * 60
 
9
 
10
  TEMPLATES_INFO = [
11
  {"name": "classic", "ai_features": []},
 
1
  import os
2
  import tempfile
3
 
4
+ MAX_CONCURRENT_JOBS = 5
5
  LOCK_DIR = os.path.join(tempfile.gettempdir(), "pycaps_locks")
6
  os.makedirs(LOCK_DIR, exist_ok=True)
7
  MAX_VIDEO_SIZE = 50 * 1024 * 1024
8
  LOCK_TTL_SECONDS = 20 * 60
9
+ SESSION_TTL_SECONDS = 60 * 60
10
 
11
  TEMPLATES_INFO = [
12
  {"name": "classic", "ai_features": []},
src/file_manager.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import shutil
3
+ import tempfile
4
+ import time
5
+ import uuid
6
+ import streamlit as st
7
+ from config import SESSION_TTL_SECONDS
8
+
9
+ def init_file_manager():
10
+ base_dir = get_base_sessions_dir()
11
+ os.makedirs(base_dir, exist_ok=True)
12
+ session_dir = get_session_dir()
13
+ os.makedirs(session_dir, exist_ok=True)
14
+ touch()
15
+ cleanup_expired_sessions()
16
+
17
+ def get_base_sessions_dir():
18
+ return os.path.join(tempfile.gettempdir(), "pycaps-sessions")
19
+
20
+ def get_session_dir():
21
+ return os.path.join(get_base_sessions_dir(), st.session_state.session_id)
22
+
23
+ def get_path(*relative_path):
24
+ return os.path.join(get_session_dir(), *relative_path)
25
+
26
+ def get_random_file_name(ext):
27
+ return get_path(f"{uuid.uuid4()}.{ext}")
28
+
29
+ def create_temp_file(suffix="", prefix="tmp", text=False):
30
+ fd, path = tempfile.mkstemp(suffix=suffix, prefix=prefix, dir=get_session_dir(), text=text)
31
+ # Abre y devuelve el archivo como objeto file abierto (similar a NamedTemporaryFile)
32
+ mode = "w+" if text else "w+b"
33
+ file_obj = os.fdopen(fd, mode)
34
+ return file_obj
35
+
36
+ def create_temp_dir(suffix="", prefix="tmp"):
37
+ return tempfile.mkdtemp(suffix=suffix, prefix=prefix, dir=get_session_dir())
38
+
39
+ def touch():
40
+ keepalive_path = os.path.join(get_session_dir(), ".keepalive")
41
+ with open(keepalive_path, "w") as f:
42
+ f.write(str(time.time()))
43
+
44
+ def cleanup_expired_sessions():
45
+ now = time.time()
46
+ for name in os.listdir(get_base_sessions_dir()):
47
+ path = os.path.join(get_base_sessions_dir(), name)
48
+ if not os.path.isdir(path):
49
+ continue
50
+ try:
51
+ keepalive_file = os.path.join(path, ".keepalive")
52
+ if os.path.exists(keepalive_file):
53
+ last_touched = os.path.getmtime(keepalive_file)
54
+ else:
55
+ last_touched = os.path.getmtime(path)
56
+ if now - last_touched > SESSION_TTL_SECONDS:
57
+ shutil.rmtree(path)
58
+ except Exception as e:
59
+ pass
60
+
61
+ def delete_current_session_dir():
62
+ shutil.rmtree(get_session_dir(), ignore_errors=True)
src/{editor → subtitle_editor}/__init__.py RENAMED
@@ -2,7 +2,7 @@ import streamlit.components.v1 as components
2
  import os
3
 
4
  _component_func = components.declare_component(
5
- "editor",
6
  path=os.path.join(os.path.dirname(os.path.abspath(__file__)))
7
  )
8
 
 
2
  import os
3
 
4
  _component_func = components.declare_component(
5
+ "subtitle_editor",
6
  path=os.path.join(os.path.dirname(os.path.abspath(__file__)))
7
  )
8
 
src/{editor → subtitle_editor}/index.html RENAMED
File without changes
src/template_editor/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit.components.v1 as components
2
+ import os
3
+
4
+ _component_func = components.declare_component(
5
+ "template_editor",
6
+ path=os.path.join(os.path.dirname(os.path.abspath(__file__)))
7
+ )
8
+
9
+ def template_editor(json, css, key=None):
10
+ component_value = _component_func(
11
+ json=json,
12
+ css=css,
13
+ key=key,
14
+ )
15
+ return component_value
src/template_editor/index.html ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <title>Template Editor</title>
7
+ <!-- CodeMirror CSS -->
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.15/codemirror.min.css">
9
+ <link rel="stylesheet"
10
+ href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.15/theme/material-darker.min.css">
11
+
12
+ <!-- CodeMirror JS y modos de lenguaje -->
13
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.15/codemirror.min.js"></script>
14
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.15/mode/javascript/javascript.min.js"></script>
15
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.15/mode/css/css.min.js"></script>
16
+
17
+ <!-- Streamlit Component Lib -->
18
+ <script
19
+ src="https://cdn.jsdelivr.net/gh/streamlit/streamlit@master/frontend/src/streamlit-component-lib.js"></script>
20
+
21
+ <style>
22
+ body {
23
+ font-family: sans-serif;
24
+ margin: 0;
25
+ padding: 0;
26
+ }
27
+
28
+ .container {
29
+ display: flex;
30
+ flex-direction: column;
31
+ height: 100vh;
32
+ }
33
+
34
+ .editors {
35
+ overflow: hidden;
36
+ }
37
+
38
+ .editor-pane {
39
+ overflow: hidden;
40
+ padding: 5px;
41
+ }
42
+
43
+ .editor-pane h3 {
44
+ margin: 5px 0;
45
+ font-size: 14px;
46
+ color: #FAFAFA;
47
+ }
48
+
49
+ .CodeMirror {
50
+ flex: 1;
51
+ border: 1px solid #444;
52
+ border-radius: 4px;
53
+ height: 390px
54
+ }
55
+
56
+ .buttons {
57
+ margin-top: 10px;
58
+ display: flex;
59
+ justify-content: flex-end;
60
+ }
61
+
62
+ .buttons button {
63
+ padding: 8px 16px;
64
+ border: none;
65
+ border-radius: 5px;
66
+ cursor: pointer;
67
+ font-weight: bold;
68
+ margin-left: 10px;
69
+ width: 100px;
70
+ }
71
+
72
+ .save-btn {
73
+ background-color: #007bff;
74
+ color: white;
75
+ }
76
+
77
+ .editor-help {
78
+ font-size: 12px;
79
+ color: #a0a0a0;
80
+ margin-top: 8px;
81
+ padding: 0 5px;
82
+ text-align: center;
83
+ }
84
+
85
+ .editor-help a {
86
+ color: #4dabf7;
87
+ text-decoration: none;
88
+ font-weight: bold;
89
+ }
90
+
91
+ .editor-help a:hover {
92
+ text-decoration: underline;
93
+ }
94
+
95
+ .editor-help code {
96
+ background-color: #333;
97
+ padding: 2px 4px;
98
+ border-radius: 3px;
99
+ font-size: 11px;
100
+ color: #d0d0d0;
101
+ }
102
+ </style>
103
+ </head>
104
+
105
+ <body>
106
+ <div class="container">
107
+ <div class="editors">
108
+ <div class="editor-pane">
109
+ <h3>styles.css</h3>
110
+ <textarea id="css-editor"></textarea>
111
+ </div>
112
+ <p class="editor-help">
113
+ Remember, any tag can be used as a CSS class (e.g., <code>.word.highlight</code>).
114
+ See all available tags in the
115
+ <a href="https://github.com/francozanardi/pycaps/blob/main/docs/TAGS.md" target="_blank" rel="noopener noreferrer">
116
+ Tagging System docs
117
+ </a>.
118
+ </p>
119
+ <div class="editor-pane">
120
+ <h3>pycaps.template.json</h3>
121
+ <textarea id="json-editor"></textarea>
122
+ </div>
123
+ <p class="editor-help">
124
+ Need help with the configuration? Check the
125
+ <a href="https://github.com/francozanardi/pycaps/blob/main/docs/CONFIG_REFERENCE.md"
126
+ target="_blank" rel="noopener noreferrer">
127
+ full JSON reference
128
+ </a>
129
+ in our documentation.
130
+ </p>
131
+ </div>
132
+ <div class="buttons">
133
+ <button class="save-btn">Save</button>
134
+ </div>
135
+ </div>
136
+
137
+ <script>
138
+ // Source: https://discuss.streamlit.io/t/code-snippet-create-components-without-any-frontend-tooling-no-react-babel-webpack-etc/13064
139
+ function sendMessageToStreamlitClient(type, data) {
140
+ const outData = Object.assign({
141
+ isStreamlitMessage: true,
142
+ type: type,
143
+ }, data);
144
+ window.parent.postMessage(outData, "*");
145
+ }
146
+
147
+ function init() {
148
+ sendMessageToStreamlitClient("streamlit:componentReady", { apiVersion: 1 });
149
+ }
150
+
151
+ function setFrameHeight(height) {
152
+ sendMessageToStreamlitClient("streamlit:setFrameHeight", { height: height });
153
+ }
154
+
155
+ // `data` puede ser cualquier valor serializable en JSON.
156
+ function sendDataToPython(data) {
157
+ sendMessageToStreamlitClient("streamlit:setComponentValue", { value: data, dataType: "json" });
158
+ }
159
+
160
+ function onDataFromPython(event) {
161
+ if (event.data.type !== "streamlit:render") return;
162
+
163
+ if (event.data.args) {
164
+ if (!window.wereEditorsInitialized) {
165
+ window.wereEditorsInitialized = true;
166
+ initializeEditors(event.data.args.json, event.data.args.css);
167
+ }
168
+ } else {
169
+ sendDataToPython({ action: "error", message: 'Unexpected error: missing JSON and CSS' });
170
+ document.querySelector('.container').innerHTML = '';
171
+ setFrameHeight(1);
172
+ }
173
+ }
174
+
175
+ let jsonEditor, cssEditor;
176
+
177
+ function initializeEditors(jsonData, cssData) {
178
+ jsonEditor = CodeMirror.fromTextArea(document.getElementById('json-editor'), {
179
+ mode: { name: "javascript", json: true },
180
+ theme: 'material-darker',
181
+ lineNumbers: true,
182
+ });
183
+ jsonEditor.setValue(JSON.stringify(jsonData, null, 2));
184
+
185
+ cssEditor = CodeMirror.fromTextArea(document.getElementById('css-editor'), {
186
+ mode: 'css',
187
+ theme: 'material-darker',
188
+ lineNumbers: true,
189
+ });
190
+ cssEditor.setValue(cssData);
191
+ }
192
+
193
+ function sendValue() {
194
+ try {
195
+ const jsonContent = JSON.parse(jsonEditor.getValue());
196
+ const cssContent = cssEditor.getValue();
197
+ sendDataToPython({
198
+ action: "save",
199
+ json_content: jsonContent,
200
+ css_content: cssContent
201
+ });
202
+ } catch (e) {
203
+ console.error("Invalid JSON format:", e);
204
+ sendDataToPython({ action: "error", message: "Invalid JSON format. Please correct it before saving." });
205
+ }
206
+ }
207
+
208
+ // Listeners
209
+ document.querySelector(".save-btn").addEventListener("click", sendValue);
210
+ window.addEventListener("message", onDataFromPython);
211
+ init();
212
+
213
+ setFrameHeight(1000);
214
+ </script>
215
+ </body>
216
+
217
+ </html>
src/ui/step1_upload.py CHANGED
@@ -3,6 +3,7 @@ import os
3
  import tempfile
4
  import shutil
5
  from pathlib import Path
 
6
  import pycaps.video.render.audio_utils as audio_utils
7
  from pycaps import WhisperAudioTranscriber
8
  from utils import go_to_step, acquire_lock_slot, handle_unexpected_exception
@@ -52,7 +53,7 @@ def render_step1():
52
  document = transcriber.transcribe(audio_path)
53
 
54
  st.session_state.transcribed_doc = document.to_dict()
55
- persisted_path = os.path.join(tempfile.gettempdir(), f"session_{st.session_state.session_id}.mp4")
56
  shutil.copy(video_path, persisted_path)
57
  st.session_state.video_path = persisted_path
58
 
 
3
  import tempfile
4
  import shutil
5
  from pathlib import Path
6
+ from file_manager import get_path
7
  import pycaps.video.render.audio_utils as audio_utils
8
  from pycaps import WhisperAudioTranscriber
9
  from utils import go_to_step, acquire_lock_slot, handle_unexpected_exception
 
53
  document = transcriber.transcribe(audio_path)
54
 
55
  st.session_state.transcribed_doc = document.to_dict()
56
+ persisted_path = get_path(f"input.mp4")
57
  shutil.copy(video_path, persisted_path)
58
  st.session_state.video_path = persisted_path
59
 
src/ui/step2_configure.py CHANGED
@@ -1,89 +1,193 @@
1
  import streamlit as st
2
  import os
3
- from pycaps import Document, TemplateLoader
4
- from utils import go_to_step, reset_all, build_pipeline_from_state, setup_api_keys, cleanup_api_keys, handle_unexpected_exception
 
 
5
  from config import TEMPLATE_NAMES, TEMPLATES_INFO
 
6
 
7
  def render_step2():
8
  st.header("Configure & Process")
9
- api_key = st.session_state.get('api_key_input')
10
- api_key_type = st.session_state.get('api_key_type')
 
11
 
12
  col1, col2 = st.columns([1, 1])
13
  with col1:
14
- render_configuration_column(api_key, api_key_type)
15
  with col2:
16
  render_preview_column()
17
 
18
- def render_configuration_column(api_key, api_key_type):
19
  st.subheader("Configuration")
20
- template_name = st.selectbox("Choose a Style", TEMPLATE_NAMES)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  st.session_state.selected_template = template_name
22
 
23
  selected_template_info = next((t for t in TEMPLATES_INFO if t["name"] == template_name), None)
 
24
  if selected_template_info and selected_template_info["ai_features"]:
25
  ai_features_str = ", ".join(selected_template_info["ai_features"])
26
- if not api_key:
27
  st.warning(f"⚠️ This template uses AI features ({ai_features_str}). "
28
  "Please provide an API key in the sidebar to enable them. "
29
  "Otherwise, they will be ignored during processing.")
30
  else:
31
  st.info(f"✨ This template uses AI features: {ai_features_str}.")
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  st.session_state.edit_requested = st.checkbox(
34
- "I want to review and edit the processed subtitles before rendering.",
35
  value=st.session_state.get('edit_requested', False)
36
  )
37
 
38
- st.write("")
39
  b_next_col, b_back_col = st.columns(2)
40
  with b_next_col:
41
  if st.button("Render Video ➡️", type="primary", use_container_width=True):
42
- with st.spinner("Applying template, effects, and tags... ⚙️"):
43
- try:
44
- pipeline = build_pipeline_from_state()
45
- if not pipeline:
46
- raise RuntimeError("Could not build pipeline. Missing video or template selection.")
47
-
48
- setup_api_keys(api_key_type, api_key)
49
- # NOTE: Assuming your pipeline has methods `prepare` and `process_document`
50
- pipeline.prepare()
51
- document = Document.from_dict(st.session_state.transcribed_doc)
52
- processed_document = pipeline.process_document(document)
53
- pipeline.close()
54
-
55
- st.session_state.processed_doc = processed_document.to_dict()
56
- go_to_step(3)
57
- st.rerun()
58
- except Exception as e:
59
- handle_unexpected_exception(e)
60
- finally:
61
- cleanup_api_keys()
62
-
63
  with b_back_col:
64
  if st.button("⬅️ Start Over", use_container_width=True):
65
  reset_all()
66
  st.rerun()
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  def render_preview_column():
69
  st.subheader("Live Preview")
70
  st.markdown("Generate a short, low-quality video preview with the selected style. This takes a few seconds.")
71
  st.warning("AI features (auto-emojis, ai tagger) are ignored in the preview.")
72
 
73
  preview_container = st.container()
74
- template_name = st.session_state.get('selected_template') # Use a local var for clarity
75
 
76
  has_preview_for_template = template_name in st.session_state.previews
77
- is_button_disabled = has_preview_for_template or st.session_state.preview_generating
78
 
79
- if st.button("Generate Preview ⚡", use_container_width=True, disabled=is_button_disabled):
 
 
 
80
  st.session_state.preview_generating = True
81
  st.rerun()
82
-
83
  if st.session_state.preview_generating:
84
  try:
85
  with st.spinner("Generating preview video... ⚡"):
86
- builder = TemplateLoader(template_name).with_input_video(st.session_state.video_path).load(False)
 
87
  pipeline = builder.build(preview_time=(0, 5))
88
  document = Document.from_dict(st.session_state.transcribed_doc)
89
 
@@ -94,15 +198,12 @@ def render_preview_column():
94
 
95
  preview_output_path = pipeline._output_video_path
96
  if preview_output_path and os.path.exists(preview_output_path):
97
- preview_container.video(preview_output_path)
98
  st.session_state.previews[template_name] = preview_output_path
99
  else:
100
- preview_container.error("Could not generate preview.")
101
  except Exception as e:
102
  handle_unexpected_exception(e)
103
  finally:
104
  st.session_state.preview_generating = False
105
  st.rerun()
106
-
107
- if has_preview_for_template:
108
- preview_container.video(st.session_state.previews[template_name])
 
1
  import streamlit as st
2
  import os
3
+ from pycaps import Document
4
+ from utils import (go_to_step, reset_all, setup_api_keys, cleanup_api_keys,
5
+ handle_unexpected_exception, load_template_files, create_pipeline_builder)
6
+ from file_manager import get_random_file_name
7
  from config import TEMPLATE_NAMES, TEMPLATES_INFO
8
+ from template_editor import template_editor
9
 
10
  def render_step2():
11
  st.header("Configure & Process")
12
+
13
+ if 'editing_mode' not in st.session_state: st.session_state.editing_mode = False
14
+ if 'edited_templates' not in st.session_state: st.session_state.edited_templates = {}
15
 
16
  col1, col2 = st.columns([1, 1])
17
  with col1:
18
+ render_configuration_column()
19
  with col2:
20
  render_preview_column()
21
 
22
+ def render_configuration_column():
23
  st.subheader("Configuration")
24
+
25
+ if st.session_state.preview_generating:
26
+ return
27
+
28
+ if st.session_state.editing_mode:
29
+ handle_editing_mode()
30
+ else:
31
+ handle_preset_selection_mode()
32
+
33
+ def handle_preset_selection_mode():
34
+ display_names = [
35
+ f"{name} [EDITED]" if (name in st.session_state.edited_templates and st.session_state.edited_templates[name]["modified"]) else name
36
+ for name in TEMPLATE_NAMES
37
+ ]
38
+
39
+ current_selection_name = st.session_state.get('selected_template', TEMPLATE_NAMES[0])
40
+ try:
41
+ current_index = TEMPLATE_NAMES.index(current_selection_name)
42
+ except ValueError:
43
+ current_index = 0
44
+
45
+ selected_display_name = st.selectbox(
46
+ "Choose a Style", display_names, index=current_index
47
+ )
48
+
49
+ # Extraer el nombre real de la template
50
+ template_name = selected_display_name.replace(" [EDITED]", "")
51
  st.session_state.selected_template = template_name
52
 
53
  selected_template_info = next((t for t in TEMPLATES_INFO if t["name"] == template_name), None)
54
+
55
  if selected_template_info and selected_template_info["ai_features"]:
56
  ai_features_str = ", ".join(selected_template_info["ai_features"])
57
+ if not st.session_state.get('api_key_input'):
58
  st.warning(f"⚠️ This template uses AI features ({ai_features_str}). "
59
  "Please provide an API key in the sidebar to enable them. "
60
  "Otherwise, they will be ignored during processing.")
61
  else:
62
  st.info(f"✨ This template uses AI features: {ai_features_str}.")
63
 
64
+ st.write("")
65
+ if st.button("✍️ Customize template", use_container_width=True):
66
+ st.session_state.editing_mode = True
67
+ # Si la template no ha sido editada antes, la cargamos desde los archivos originales
68
+ if template_name not in st.session_state.edited_templates:
69
+ initial_data = load_template_files(template_name)
70
+ st.session_state.edited_templates[template_name] = {
71
+ "json": initial_data["json"],
72
+ "css": initial_data["css"],
73
+ "resources_zip": None,
74
+ "modified": False
75
+ }
76
+ st.rerun()
77
+
78
+ st.divider()
79
+
80
  st.session_state.edit_requested = st.checkbox(
81
+ "I want to review and edit the processed subtitles before rendering",
82
  value=st.session_state.get('edit_requested', False)
83
  )
84
 
 
85
  b_next_col, b_back_col = st.columns(2)
86
  with b_next_col:
87
  if st.button("Render Video ➡️", type="primary", use_container_width=True):
88
+ process_and_advance()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  with b_back_col:
90
  if st.button("⬅️ Start Over", use_container_width=True):
91
  reset_all()
92
  st.rerun()
93
 
94
+ def handle_editing_mode():
95
+ template_name = st.session_state.selected_template
96
+ st.info(f"You are customizing the **'{template_name}'** template.")
97
+
98
+ current_edit_data = st.session_state.edited_templates[template_name]
99
+ editor_result = template_editor(
100
+ json=current_edit_data["json"],
101
+ css=current_edit_data["css"],
102
+ key=f"editor_{template_name}"
103
+ )
104
+
105
+ if editor_result:
106
+ if editor_result.get("action") == "save":
107
+ old_json = st.session_state.edited_templates[template_name].get("json", None)
108
+ old_css = st.session_state.edited_templates[template_name].get("css", None)
109
+ new_json = editor_result["json_content"]
110
+ new_css = editor_result["css_content"]
111
+ # This is a little bit tricky, but this logic can be executed multiple times for the same preview (because of using st.rerun)
112
+ # So, if `was_content_modified` is not used, we are going to remove the preview when this is re-rendered but the code was not changed
113
+ # On the other hand, modified must be always "True", since if we use `was_content_modified` here, it will be "False" when this is re-rendered for the same saving
114
+ was_content_modified = old_json != new_json or old_css != new_css
115
+ st.session_state.edited_templates[template_name]["json"] = new_json
116
+ st.session_state.edited_templates[template_name]["css"] = new_css
117
+ st.session_state.edited_templates[template_name]["modified"] = True
118
+ if template_name in st.session_state.previews and was_content_modified:
119
+ del st.session_state.previews[template_name]
120
+ st.toast("✅ Template saved!")
121
+ st.success("✅ Template saved!")
122
+ elif editor_result.get("action") == "error":
123
+ st.error(editor_result.get("message", "An error occurred in the editor."))
124
+
125
+ st.session_state.edited_templates[template_name]["resources_zip"] = st.file_uploader(
126
+ "(Optional) Upload a `.zip` to add or overwrite resources", type=["zip"],
127
+ key=f"uploader_{template_name}"
128
+ )
129
+
130
+ edit_buttons_col1, edit_buttons_col2 = st.columns(2)
131
+ with edit_buttons_col1:
132
+ if st.button("Back to Templates", use_container_width=True):
133
+ st.session_state.editing_mode = False
134
+ st.rerun()
135
+ with edit_buttons_col2:
136
+ if st.button("Reset to Original", help="Discards all edits for this template.", use_container_width=True):
137
+ if template_name in st.session_state.edited_templates:
138
+ del st.session_state.edited_templates[template_name]
139
+ if template_name in st.session_state.previews:
140
+ del st.session_state.previews[template_name]
141
+ st.toast(f"Template '{template_name}' has been reset to its original state.")
142
+ st.success(f"Template '{template_name}' has been reset to its original state.")
143
+ st.session_state.editing_mode = False
144
+ st.rerun()
145
+
146
+ def process_and_advance():
147
+ with st.spinner("Applying configuration..."):
148
+ try:
149
+ builder = create_pipeline_builder()
150
+ builder.with_input_video(st.session_state.video_path)
151
+ setup_api_keys(st.session_state.api_key_type, st.session_state.api_key_input)
152
+
153
+ pipeline = builder.build()
154
+ pipeline.prepare()
155
+ document = Document.from_dict(st.session_state.transcribed_doc)
156
+ processed_document = pipeline.process_document(document)
157
+ pipeline.close()
158
+
159
+ st.session_state.processed_doc = processed_document.to_dict()
160
+ go_to_step(3)
161
+ st.rerun()
162
+
163
+ except Exception as e:
164
+ handle_unexpected_exception(e)
165
+ finally:
166
+ cleanup_api_keys()
167
+
168
  def render_preview_column():
169
  st.subheader("Live Preview")
170
  st.markdown("Generate a short, low-quality video preview with the selected style. This takes a few seconds.")
171
  st.warning("AI features (auto-emojis, ai tagger) are ignored in the preview.")
172
 
173
  preview_container = st.container()
174
+ template_name = st.session_state.get('selected_template')
175
 
176
  has_preview_for_template = template_name in st.session_state.previews
177
+ should_disable_button = has_preview_for_template or st.session_state.preview_generating
178
 
179
+ if has_preview_for_template:
180
+ preview_container.video(st.session_state.previews[template_name])
181
+
182
+ if st.button("Generate Preview ⚡", use_container_width=True, disabled=should_disable_button):
183
  st.session_state.preview_generating = True
184
  st.rerun()
185
+
186
  if st.session_state.preview_generating:
187
  try:
188
  with st.spinner("Generating preview video... ⚡"):
189
+ builder = create_pipeline_builder()
190
+ builder.with_input_video(st.session_state.video_path)
191
  pipeline = builder.build(preview_time=(0, 5))
192
  document = Document.from_dict(st.session_state.transcribed_doc)
193
 
 
198
 
199
  preview_output_path = pipeline._output_video_path
200
  if preview_output_path and os.path.exists(preview_output_path):
 
201
  st.session_state.previews[template_name] = preview_output_path
202
  else:
203
+ raise RuntimeError("Could not generate preview.")
204
  except Exception as e:
205
  handle_unexpected_exception(e)
206
  finally:
207
  st.session_state.preview_generating = False
208
  st.rerun()
209
+
 
 
src/ui/step3_edit.py CHANGED
@@ -1,5 +1,5 @@
1
  import streamlit as st
2
- from editor import subtitle_editor
3
  from utils import go_to_step, handle_unexpected_exception
4
 
5
  def render_step3():
 
1
  import streamlit as st
2
+ from subtitle_editor import subtitle_editor
3
  from utils import go_to_step, handle_unexpected_exception
4
 
5
  def render_step3():
src/ui/step4_render.py CHANGED
@@ -1,7 +1,7 @@
1
  import streamlit as st
2
  import os
3
  from pycaps import Document
4
- from utils import go_to_step, build_pipeline_from_state, setup_api_keys, handle_unexpected_exception
5
 
6
  def render_step4():
7
  st.header("Final Render")
@@ -10,7 +10,7 @@ def render_step4():
10
 
11
  try:
12
  with st.spinner("Rendering final video... This is the last step! 🎬"):
13
- pipeline = build_pipeline_from_state()
14
  if not pipeline:
15
  raise RuntimeError("Could not build pipeline for rendering.")
16
 
 
1
  import streamlit as st
2
  import os
3
  from pycaps import Document
4
+ from utils import go_to_step, create_pipeline_builder, setup_api_keys, handle_unexpected_exception
5
 
6
  def render_step4():
7
  st.header("Final Render")
 
10
 
11
  try:
12
  with st.spinner("Rendering final video... This is the last step! 🎬"):
13
+ pipeline = create_pipeline_builder().with_input_video(st.session_state.video_path).build()
14
  if not pipeline:
15
  raise RuntimeError("Could not build pipeline for rendering.")
16
 
src/ui/step5_view.py CHANGED
@@ -1,12 +1,10 @@
1
  import streamlit as st
2
- from utils import go_to_step, reset_all, release_lock_slot, acquire_lock_slot, display_video
3
 
4
  def render_step5():
5
  st.header("Your Video is Ready!")
6
 
7
- if st.session_state.lock_file_path:
8
- release_lock_slot(st.session_state.lock_file_path)
9
- st.session_state.lock_file_path = None
10
 
11
  if 'final_video_path' in st.session_state and st.session_state.final_video_path:
12
  display_video(st.session_state.final_video_path)
 
1
  import streamlit as st
2
+ from utils import go_to_step, reset_all, release_lock_slot_if_needed, acquire_lock_slot, display_video
3
 
4
  def render_step5():
5
  st.header("Your Video is Ready!")
6
 
7
+ release_lock_slot_if_needed()
 
 
8
 
9
  if 'final_video_path' in st.session_state and st.session_state.final_video_path:
10
  display_video(st.session_state.final_video_path)
src/utils.py CHANGED
@@ -4,9 +4,16 @@ import time
4
  import glob
5
  import uuid
6
  from pathlib import Path
 
 
 
 
 
 
 
7
 
8
  from pycaps.api import ApiKeyService
9
- from pycaps import TemplateLoader
10
  from config import LOCK_DIR, LOCK_TTL_SECONDS, MAX_CONCURRENT_JOBS
11
 
12
  # --- Lock Management ---
@@ -65,14 +72,14 @@ def cleanup_api_keys():
65
  def go_to_step(step):
66
  st.session_state.current_step = step
67
 
68
- def reset_all():
69
- if 'lock_file_path' in st.session_state and st.session_state.lock_file_path:
70
  release_lock_slot(st.session_state.lock_file_path)
71
-
72
- persisted_keys = ['api_key_type', 'api_key_input']
73
- if 'video_path' in st.session_state and st.session_state.video_path and os.path.exists(st.session_state.video_path):
74
- os.remove(st.session_state.video_path)
75
 
 
 
 
76
  for key in list(st.session_state.keys()):
77
  if key not in persisted_keys:
78
  del st.session_state[key]
@@ -100,10 +107,49 @@ def initialize_session_state():
100
 
101
  # --- Pipeline & Display ---
102
 
103
- def build_pipeline_from_state():
104
- if not st.session_state.video_path or not st.session_state.selected_template:
105
- return None
106
- return TemplateLoader(st.session_state.selected_template).with_input_video(st.session_state.video_path).load()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
  def handle_unexpected_exception(e):
109
  st.session_state.error_message = f"Unexpected error: {e}"
@@ -123,3 +169,30 @@ def display_video(video_path):
123
  "⬇️ Download Video", video_bytes,
124
  f"pycaps_{Path(video_path).stem}.mp4", "video/mp4"
125
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import glob
5
  import uuid
6
  from pathlib import Path
7
+ import importlib.resources as resources
8
+ from importlib.abc import Traversable
9
+ import zipfile
10
+ import json
11
+ import shutil
12
+ from pathlib import Path
13
+ from file_manager import create_temp_dir, get_random_file_name
14
 
15
  from pycaps.api import ApiKeyService
16
+ from pycaps import TemplateLoader, JsonConfigLoader
17
  from config import LOCK_DIR, LOCK_TTL_SECONDS, MAX_CONCURRENT_JOBS
18
 
19
  # --- Lock Management ---
 
72
  def go_to_step(step):
73
  st.session_state.current_step = step
74
 
75
+ def release_lock_slot_if_needed():
76
+ if st.session_state.lock_file_path:
77
  release_lock_slot(st.session_state.lock_file_path)
78
+ st.session_state.lock_file_path = None
 
 
 
79
 
80
+ def reset_all():
81
+ release_lock_slot_if_needed()
82
+ persisted_keys = ['api_key_type', 'api_key_input']
83
  for key in list(st.session_state.keys()):
84
  if key not in persisted_keys:
85
  del st.session_state[key]
 
107
 
108
  # --- Pipeline & Display ---
109
 
110
+ def create_pipeline_builder():
111
+ template_name = st.session_state.selected_template
112
+ if template_name not in st.session_state.edited_templates:
113
+ return (
114
+ TemplateLoader(template_name)
115
+ .load(False)
116
+ .should_save_subtitle_data(False)
117
+ .with_output_video(get_random_file_name("mp4"))
118
+ )
119
+
120
+ edited_data = st.session_state.edited_templates[template_name]
121
+ temp_dir = create_temp_dir()
122
+ original_template_path = get_template_path(template_name)
123
+ resources_dir = Path(temp_dir) / "resources"
124
+ os.makedirs(resources_dir, exist_ok=True)
125
+
126
+ # Primero, copiar recursos originales si existen
127
+ if original_template_path and (original_template_path / "resources").is_dir():
128
+ edited_data["json"]["resources"] = "resources"
129
+ shutil.copytree(str(original_template_path / "resources"), str(resources_dir), dirs_exist_ok=True)
130
+
131
+ # Luego, descomprimir y sobrescribir con los recursos subidos por el usuario
132
+ if edited_data.get('resources_zip'):
133
+ edited_data["json"]["resources"] = "resources"
134
+ with zipfile.ZipFile(edited_data['resources_zip'], 'r') as zip_ref:
135
+ zip_ref.extractall(resources_dir)
136
+
137
+ # Crear los archivos de configuración en el directorio temporal
138
+ config_path = Path(temp_dir) / "pycaps.json"
139
+ with open(config_path, "w") as f:
140
+ edited_data["json"]["css"] = "styles.css"
141
+ json.dump(edited_data["json"], f, indent=2)
142
+
143
+ css_path = Path(temp_dir) / "styles.css"
144
+ with open(css_path, "w") as f:
145
+ f.write(edited_data["css"])
146
+
147
+ return (
148
+ JsonConfigLoader(str(config_path))
149
+ .load(False)
150
+ .should_save_subtitle_data(False)
151
+ .with_output_video(get_random_file_name("mp4"))
152
+ )
153
 
154
  def handle_unexpected_exception(e):
155
  st.session_state.error_message = f"Unexpected error: {e}"
 
169
  "⬇️ Download Video", video_bytes,
170
  f"pycaps_{Path(video_path).stem}.mp4", "video/mp4"
171
  )
172
+
173
+ def get_template_path(template_name: str) -> Traversable:
174
+ """Obtiene la ruta a la carpeta de una template predefinida."""
175
+ try:
176
+ return resources.files(f"pycaps.template.preset.{template_name}")
177
+ except ModuleNotFoundError:
178
+ return None
179
+
180
+ def load_template_files(template_name: str) -> dict:
181
+ """Carga el contenido de los archivos de una template predefinida."""
182
+ template_path = get_template_path(template_name)
183
+ if not template_path:
184
+ return {"json": {}, "css": ""}
185
+
186
+ try:
187
+ with (template_path / "pycaps.template.json").open("r", encoding="utf-8") as f:
188
+ json_content = json.load(f)
189
+ except FileNotFoundError:
190
+ json_content = {}
191
+
192
+ try:
193
+ with (template_path / "styles.css").open("r", encoding="utf-8") as f:
194
+ css_content = f.read()
195
+ except FileNotFoundError:
196
+ css_content = ""
197
+
198
+ return {"json": json_content, "css": css_content}