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

big refactor & adding video previews

Browse files
Dockerfile CHANGED
@@ -9,9 +9,6 @@ RUN apt-get update && apt-get install -y \
9
  software-properties-common \
10
  git \
11
  ffmpeg \
12
- libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libdbus-1-3 \
13
- libxkbcommon0 libatspi2.0-0 libx11-6 libxcomposite1 libxdamage1 libxext6 \
14
- libxfixes3 libxrandr2 libgbm1 libasound2 \
15
  && rm -rf /var/lib/apt/lists/*
16
 
17
  WORKDIR /app
@@ -37,4 +34,4 @@ EXPOSE 8501
37
 
38
  HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
39
 
40
- ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
9
  software-properties-common \
10
  git \
11
  ffmpeg \
 
 
 
12
  && rm -rf /var/lib/apt/lists/*
13
 
14
  WORKDIR /app
 
34
 
35
  HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
36
 
37
+ ENTRYPOINT ["streamlit", "run", "src/app.py", "--server.port=8501", "--server.address=0.0.0.0"]
requirements.txt CHANGED
@@ -1,2 +1,3 @@
1
  streamlit
2
  git+https://github.com/francozanardi/pycaps.git
 
 
1
  streamlit
2
  git+https://github.com/francozanardi/pycaps.git
3
+ openai
src/app.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import logging
3
+ from pycaps import logger
4
+
5
+ from utils import initialize_session_state, reset_all, get_queue_status
6
+ from ui.sidebar import render_sidebar
7
+ from ui.step1_upload import render_step1
8
+ 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>
20
+ .stElementContainer > video {
21
+ max-height: 50vh;
22
+ }
23
+ </style>
24
+ """, unsafe_allow_html=True)
25
+
26
+ render_sidebar()
27
+
28
+ step_router = {
29
+ 1: render_step1,
30
+ 2: render_step2,
31
+ 3: render_step3,
32
+ 4: render_step4,
33
+ 5: render_step5,
34
+ }
35
+
36
+ if st.session_state.error_message:
37
+ st.error(st.session_state.error_message)
38
+ st.warning("Your session has been reset due to an error. Please try again.")
39
+ if st.button("🏠 Start Over"):
40
+ reset_all()
41
+ st.rerun()
42
+ else:
43
+ current_step = st.session_state.current_step
44
+ render_function = step_router.get(current_step)
45
+
46
+ if render_function:
47
+ st.session_state.active_jobs = get_queue_status()
48
+ render_function()
49
+ else:
50
+ st.error("Invalid step. Resetting application.")
51
+ reset_all()
52
+ st.rerun()
src/config.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": []},
12
+ {"name": "word-focus", "ai_features": []},
13
+ {"name": "line-focus", "ai_features": []},
14
+ {"name": "minimalist", "ai_features": []},
15
+ {"name": "neo-minimal", "ai_features": ["AI Tagger"]},
16
+ {"name": "hype", "ai_features": ["Auto-Emoji"]},
17
+ {"name": "retro-gaming", "ai_features": []},
18
+ {"name": "vibrant", "ai_features": []},
19
+ ]
20
+ TEMPLATE_NAMES = [t["name"] for t in TEMPLATES_INFO]
src/streamlit_app.py DELETED
@@ -1,245 +0,0 @@
1
- import streamlit as st
2
- import os
3
- import tempfile
4
- from pathlib import Path
5
- import shutil
6
- import uuid
7
- import glob
8
- from editor import subtitle_editor
9
-
10
- from pycaps import Document, WhisperAudioTranscriber, TemplateLoader, logger
11
- from pycaps.api import ApiKeyService
12
- import pycaps.video.render.audio_utils as audio_utils
13
- import logging
14
-
15
- MAX_CONCURRENT_JOBS = 2
16
- LOCK_DIR = os.path.join(tempfile.gettempdir(), "pycaps_locks")
17
- os.makedirs(LOCK_DIR, exist_ok=True)
18
- MAX_VIDEO_SIZE = 50 * 1024 * 1024
19
- logger.set_logging_level(logging.DEBUG)
20
-
21
- def acquire_lock_slot():
22
- current_jobs = glob.glob(os.path.join(LOCK_DIR, "*.lock"))
23
- if len(current_jobs) >= MAX_CONCURRENT_JOBS: return None
24
- lock_id = str(uuid.uuid4())
25
- lock_file_path = os.path.join(LOCK_DIR, f"{lock_id}.lock")
26
- with open(lock_file_path, "w") as f: f.write(str(os.getpid()))
27
- return lock_file_path
28
-
29
- def release_lock_slot(lock_file_path):
30
- if lock_file_path and os.path.exists(lock_file_path):
31
- try: os.remove(lock_file_path)
32
- except OSError: pass
33
-
34
- def get_queue_status():
35
- current_jobs = len(glob.glob(os.path.join(LOCK_DIR, "*.lock")))
36
- return current_jobs, MAX_CONCURRENT_JOBS
37
-
38
- def setup_api_keys(key_type: str, key: str):
39
- if key:
40
- if key_type == "Pycaps API (Recommended)": ApiKeyService.set(key)
41
- elif key_type == "OpenAI API": os.environ["PYCAPS_OPENAI_API_KEY"] = key
42
-
43
- def cleanup_api_keys():
44
- if ApiKeyService.has(): ApiKeyService.remove()
45
- if "PYCAPS_OPENAI_API_KEY" in os.environ: del os.environ["PYCAPS_OPENAI_API_KEY"]
46
-
47
- def display_video(video_path):
48
- with st.container():
49
- st.video(video_path)
50
- with open(video_path, "rb") as file: video_bytes = file.read()
51
- st.download_button("⬇️ Download Video", video_bytes, f"pycaps_{Path(video_path).stem}.mp4", "video/mp4")
52
-
53
- def build_pipeline_from_state():
54
- if not st.session_state.video_path or not st.session_state.selected_template:
55
- return None
56
-
57
- builder = TemplateLoader(st.session_state.selected_template).with_input_video(st.session_state.video_path).load(False)
58
- pipeline = builder.build()
59
- return pipeline
60
-
61
- def go_to_step(step): st.session_state.current_step = step
62
- def reset_all():
63
- persisted_keys = ['api_key_type', 'api_key_input']
64
- if 'video_path' in st.session_state and st.session_state.video_path and os.path.exists(st.session_state.video_path):
65
- os.remove(st.session_state.video_path)
66
- for key in list(st.session_state.keys()):
67
- if key not in persisted_keys: del st.session_state[key]
68
- go_to_step(1)
69
-
70
- if 'current_step' not in st.session_state: st.session_state.current_step = 1
71
- if 'video_path' not in st.session_state: st.session_state.video_path = None
72
- if 'transcribed_doc' not in st.session_state: st.session_state.transcribed_doc = None
73
- if 'processed_doc' not in st.session_state: st.session_state.processed_doc = None
74
- if 'edit_requested' not in st.session_state: st.session_state.edit_requested = False
75
- if 'final_video_path' not in st.session_state: st.session_state.final_video_path = None
76
- if 'session_id' not in st.session_state: st.session_state.session_id = str(uuid.uuid4())
77
- if 'selected_template' not in st.session_state: st.session_state.selected_template = None
78
-
79
- st.set_page_config(layout="wide", page_title="Pycaps Demo")
80
- st.title("🎬 Pycaps Demo")
81
- st.markdown(f"""
82
- <style>
83
- .stElementContainer > video {{
84
- max-height: 60vh;
85
- }}
86
- </style>
87
- """, unsafe_allow_html=True)
88
- with st.sidebar:
89
- st.header("⚙️ API Configuration")
90
- api_key_type = st.radio("Select API Key Type", ("Pycaps API (Recommended)", "OpenAI API"), key="api_key_type")
91
- api_key = st.text_input("Enter your API Key", type="password", key="api_key_input")
92
- st.markdown("---")
93
- active_jobs, max_jobs = get_queue_status()
94
- st.metric(label="Jobs Running", value=f"{active_jobs} / {max_jobs}")
95
-
96
- # ==============================================================================
97
- # STEP 1: UPLOAD & TRANSCRIBE
98
- # ==============================================================================
99
- if st.session_state.current_step == 1:
100
- st.header("Upload Your Video")
101
- uploaded_file = st.file_uploader("Select a video file (max 50MB)", type=["mp4", "mov"], key=f"uploader_{st.session_state.session_id}")
102
- if uploaded_file:
103
- if uploaded_file.size > MAX_VIDEO_SIZE:
104
- st.error(f"File is too large ({uploaded_file.size / (1024*1024):.1f}MB). Max is {MAX_VIDEO_SIZE // (1024*1024)}MB.")
105
- elif st.button("Start", type="primary"):
106
- with tempfile.TemporaryDirectory() as temp_dir:
107
- video_path = Path(temp_dir) / uploaded_file.name
108
- with open(video_path, "wb") as f: f.write(uploaded_file.getbuffer())
109
- with st.spinner("Analyzing audio... 🎧"):
110
- try:
111
- audio_path = os.path.join(temp_dir, "audio.wav")
112
- audio_utils.extract_audio_for_whisper(str(video_path), audio_path)
113
- transcriber = WhisperAudioTranscriber(model_size="base")
114
- document = transcriber.transcribe(audio_path)
115
-
116
- st.session_state.transcribed_doc = document.to_dict()
117
- persisted_path = os.path.join(tempfile.gettempdir(), f"session_{st.session_state.session_id}.mp4")
118
- shutil.copy(video_path, persisted_path)
119
- st.session_state.video_path = persisted_path
120
- go_to_step(2)
121
- st.rerun()
122
- except Exception as e:
123
- st.error(f"Transcription failed: {e}")
124
- import traceback; traceback.print_exc()
125
-
126
- # ==============================================================================
127
- # STEP 2: CONFIGURE TEMPLATE AND PROCESS
128
- # ==============================================================================
129
- elif st.session_state.current_step == 2:
130
- st.header("Configure & Process")
131
- template_name = st.selectbox("Choose a Style", ["classic", "word-focus", "line-focus", "minimalist", "neo-minimal", "hype", "retro-gaming", "vibrant"])
132
- st.session_state.edit_requested = st.checkbox("I want to review and edit the processed subtitles before rendering.", value=st.session_state.edit_requested)
133
-
134
- if st.button("Next", type="primary"):
135
- st.session_state.selected_template = template_name
136
- with st.spinner("Applying template, effects, and tags... ⚙️"):
137
- try:
138
- pipeline = build_pipeline_from_state()
139
- if not pipeline:
140
- raise RuntimeError("Could not build pipeline. Missing video or template selection.")
141
-
142
- setup_api_keys(api_key_type, api_key)
143
- pipeline.prepare()
144
- document = Document.from_dict(st.session_state.transcribed_doc)
145
- processed_document = pipeline.process_document(document)
146
- pipeline.close()
147
-
148
- st.session_state.processed_doc = processed_document.to_dict()
149
- go_to_step(3)
150
- st.rerun()
151
-
152
-
153
- except Exception as e:
154
- st.error(f"Processing failed: {e}")
155
- import traceback; traceback.print_exc()
156
- finally:
157
- cleanup_api_keys()
158
-
159
- if st.button("⬅️ Back"): reset_all(); st.rerun()
160
-
161
- # ==============================================================================
162
- # STEP 3: EDIT (OPTIONAL) & RENDER
163
- # ==============================================================================
164
- elif st.session_state.current_step == 3:
165
- if st.session_state.edit_requested:
166
- st.header("Edit Subtitles")
167
- st.markdown("Make your changes in the editor below. Your progress is saved automatically when you click 'Save' or 'Cancel'.")
168
-
169
- editor_result = subtitle_editor(
170
- initial_document=st.session_state.processed_doc,
171
- key=f"editor_{st.session_state.session_id}"
172
- )
173
-
174
- if editor_result is not None:
175
- if editor_result.get("action") == "save":
176
- st.session_state.processed_doc = editor_result.get("document")
177
- st.toast("✅ Subtitles saved!")
178
- elif editor_result.get("action") == "cancel":
179
- st.toast("Editing cancelled.")
180
-
181
- go_to_step(4)
182
- st.rerun()
183
-
184
- else:
185
- go_to_step(4)
186
- st.rerun()
187
-
188
- # ==============================================================================
189
- # STEP 4: RENDER & VIEW
190
- # ==============================================================================
191
- elif st.session_state.current_step == 4:
192
- st.header("Final Render")
193
-
194
- lock_file = acquire_lock_slot()
195
- if not lock_file:
196
- st.warning("🚧 Renderer is at full capacity. Please try again.")
197
- if st.button("⬅️ Go Back to Configuration"): go_to_step(2); st.rerun()
198
- else:
199
- try:
200
- with st.spinner("Rendering final video... This is the last step! 🎬"):
201
- pipeline = build_pipeline_from_state()
202
- if not pipeline:
203
- raise RuntimeError("Could not build pipeline for rendering.")
204
-
205
- setup_api_keys(api_key_type, api_key)
206
- pipeline.prepare()
207
-
208
- document_to_render = Document.from_dict(st.session_state.processed_doc)
209
-
210
- pipeline.render(document_to_render)
211
-
212
- if pipeline._output_video_path and os.path.exists(pipeline._output_video_path):
213
- st.session_state.final_video_path = pipeline._output_video_path
214
- go_to_step(5)
215
- st.rerun()
216
- else:
217
- st.error("Render failed. Check the logs.")
218
- finally:
219
- release_lock_slot(lock_file)
220
- if 'pipeline_instance' in st.session_state:
221
- st.session_state.pipeline_instance.close()
222
- del st.session_state.pipeline_instance
223
-
224
- # ==============================================================================
225
- # STEP 5: VIEW & DOWNLOAD
226
- # ==============================================================================
227
- elif st.session_state.current_step == 5:
228
- st.header("Your Video is Ready!")
229
- if 'final_video_path' in st.session_state and st.session_state.final_video_path:
230
- display_video(st.session_state.final_video_path)
231
- else:
232
- st.error("Could not find the final video.")
233
-
234
- col1, col2 = st.columns(2)
235
- with col1:
236
- if st.button("⬅️ Choose Another Style", use_container_width=True):
237
- keys_to_delete = ['processed_doc', 'final_video_path', 'edit_requested']
238
- for key in keys_to_delete:
239
- if key in st.session_state: del st.session_state[key]
240
- go_to_step(2)
241
- st.rerun()
242
- with col2:
243
- if st.button("🏠 Start with a New Video", use_container_width=True):
244
- reset_all()
245
- st.rerun()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/ui/__init__.py ADDED
File without changes
src/ui/sidebar.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ from utils import get_queue_status
3
+ from config import MAX_CONCURRENT_JOBS
4
+
5
+ def render_sidebar():
6
+ with st.sidebar:
7
+ st.header("⚙️ API Configuration")
8
+ st.info(
9
+ "API keys are optional and only required for AI features "
10
+ "like **Auto-Emoji** or **AI Tagger**."
11
+ )
12
+ st.radio("Select API Key Type", ("OpenAI API"), key="api_key_type")
13
+ st.text_input("Enter your API Key", type="password", key="api_key_input")
14
+
15
+ st.markdown("---")
16
+
17
+ st.header("📊 Job Status")
18
+ active_jobs = get_queue_status()
19
+ st.metric(
20
+ label="Concurrent Jobs Running",
21
+ value=f"{active_jobs} / {MAX_CONCURRENT_JOBS}"
22
+ )
23
+
24
+ st.markdown("---")
25
+
26
+ st.header("About Pycaps")
27
+ st.markdown(
28
+ "**Pycaps** is an open-source tool for adding stylish, "
29
+ "animated subtitles to videos."
30
+ )
31
+ st.markdown(
32
+ "[⭐ Star on GitHub](https://github.com/francozanardi/pycaps)"
33
+ )
34
+ st.markdown(
35
+ "[📄 Read the Docs](https://github.com/francozanardi/pycaps/blob/main/README.md)"
36
+ )
src/ui/step1_upload.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ 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
9
+ from config import MAX_VIDEO_SIZE, MAX_CONCURRENT_JOBS
10
+
11
+ def render_step1():
12
+ st.header("Upload Your Video")
13
+
14
+ if st.session_state.active_jobs >= MAX_CONCURRENT_JOBS:
15
+ st.warning("🚧 All our processing slots are currently busy. Please check back in a few minutes.")
16
+ st.info("Tip: You can also duplicate this space and get your own private and free, full-speed version instantly!")
17
+ st.progress(1.0)
18
+ if st.button("Refresh Status"):
19
+ st.rerun()
20
+ return
21
+
22
+ uploaded_file = st.file_uploader(
23
+ f"Select a video file (max {MAX_VIDEO_SIZE // (1024*1024)}MB)",
24
+ type=["mp4", "mov"],
25
+ key=f"uploader_{st.session_state.session_id}"
26
+ )
27
+
28
+ if not uploaded_file:
29
+ return
30
+
31
+ if uploaded_file.size > MAX_VIDEO_SIZE:
32
+ st.error(f"File is too large ({uploaded_file.size / (1024*1024):.1f}MB). Max is {MAX_VIDEO_SIZE // (1024*1024)}MB.")
33
+ return
34
+
35
+ if st.button("Start", type="primary"):
36
+ lock_file = acquire_lock_slot()
37
+ if not lock_file:
38
+ st.error("Sorry, all slots were taken just now. Please try again.")
39
+ st.rerun()
40
+
41
+ st.session_state.lock_file_path = lock_file
42
+ try:
43
+ with tempfile.TemporaryDirectory() as temp_dir:
44
+ video_path = Path(temp_dir) / uploaded_file.name
45
+ with open(video_path, "wb") as f:
46
+ f.write(uploaded_file.getbuffer())
47
+
48
+ with st.spinner("Analyzing audio... 🎧"):
49
+ audio_path = os.path.join(temp_dir, "audio.wav")
50
+ audio_utils.extract_audio_for_whisper(str(video_path), audio_path)
51
+ transcriber = WhisperAudioTranscriber(model_size="base")
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
+
59
+ go_to_step(2)
60
+ st.rerun()
61
+ except Exception as e:
62
+ handle_unexpected_exception(e)
src/ui/step2_configure.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
90
+ pipeline.prepare()
91
+ processed_document = pipeline.process_document(document)
92
+ pipeline.render(processed_document)
93
+ pipeline.close()
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])
src/ui/step3_edit.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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():
6
+ if not st.session_state.edit_requested:
7
+ go_to_step(4)
8
+ st.rerun()
9
+
10
+ st.header("Edit Subtitles")
11
+ st.markdown("Make your changes in the editor below. Clicking 'Save' applies them, while 'Cancel' discards them.")
12
+
13
+ editor_result = subtitle_editor(
14
+ initial_document=st.session_state.processed_doc,
15
+ key=f"editor_{st.session_state.session_id}"
16
+ )
17
+
18
+ if editor_result is not None:
19
+ try:
20
+ if editor_result.get("action") == "save":
21
+ st.session_state.processed_doc = editor_result.get("document")
22
+ st.toast("✅ Subtitles saved!")
23
+ elif editor_result.get("action") == "cancel":
24
+ st.toast("Editing cancelled. Changes ignored.")
25
+
26
+ go_to_step(4)
27
+ st.rerun()
28
+ except Exception as e:
29
+ handle_unexpected_exception(e)
src/ui/step4_render.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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")
8
+ api_key = st.session_state.get('api_key_input')
9
+ api_key_type = st.session_state.get('api_key_type')
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
+
17
+ setup_api_keys(api_key_type, api_key)
18
+ pipeline.prepare()
19
+ document_to_render = Document.from_dict(st.session_state.processed_doc)
20
+ pipeline.render(document_to_render)
21
+
22
+ if pipeline._output_video_path and os.path.exists(pipeline._output_video_path):
23
+ st.session_state.final_video_path = pipeline._output_video_path
24
+ go_to_step(5)
25
+ st.rerun()
26
+ else:
27
+ st.error("Render failed. Check the logs.")
28
+ except Exception as e:
29
+ handle_unexpected_exception(e)
src/ui/step5_view.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
13
+ else:
14
+ st.error("Could not find the final video.")
15
+
16
+ col1, col2 = st.columns(2)
17
+ with col1:
18
+ if st.button("⬅️ Choose Another Style", use_container_width=True):
19
+ lock_file = acquire_lock_slot()
20
+ if not lock_file:
21
+ st.warning("🚧 All our processing slots are currently busy. Please check back in a few minutes.")
22
+ else:
23
+ st.session_state.lock_file_path = lock_file
24
+ keys_to_delete = ['processed_doc', 'final_video_path', 'edit_requested']
25
+ for key in keys_to_delete:
26
+ if key in st.session_state:
27
+ del st.session_state[key]
28
+ go_to_step(2)
29
+ st.rerun()
30
+
31
+ with col2:
32
+ if st.button("🏠 Start with a New Video", use_container_width=True):
33
+ reset_all()
34
+ st.rerun()
src/utils.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import os
3
+ 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 ---
13
+
14
+ def release_lock_slot(lock_file_path):
15
+ if lock_file_path and os.path.exists(lock_file_path):
16
+ try:
17
+ os.remove(lock_file_path)
18
+ except OSError:
19
+ pass
20
+
21
+ def cleanup_stale_locks():
22
+ for lock_file in glob.glob(os.path.join(LOCK_DIR, "*.lock")):
23
+ try:
24
+ timestamp_str = Path(lock_file).stem.split('_')[0]
25
+ creation_time = float(timestamp_str)
26
+ if time.time() - creation_time > LOCK_TTL_SECONDS:
27
+ release_lock_slot(lock_file)
28
+ except (ValueError, IndexError):
29
+ release_lock_slot(lock_file)
30
+
31
+ def acquire_lock_slot():
32
+ cleanup_stale_locks()
33
+ current_jobs = len(glob.glob(os.path.join(LOCK_DIR, "*.lock")))
34
+ if current_jobs >= MAX_CONCURRENT_JOBS:
35
+ return None
36
+
37
+ lock_id = f"{time.time()}_{uuid.uuid4()}"
38
+ lock_file_path = os.path.join(LOCK_DIR, f"{lock_id}.lock")
39
+ with open(lock_file_path, "w") as f:
40
+ f.write(str(os.getpid()))
41
+ return lock_file_path
42
+
43
+ def get_queue_status():
44
+ cleanup_stale_locks()
45
+ current_jobs = len(glob.glob(os.path.join(LOCK_DIR, "*.lock")))
46
+ return current_jobs
47
+
48
+ # --- API Key Management ---
49
+
50
+ def setup_api_keys(key_type: str, key: str):
51
+ if key:
52
+ if key_type == "Pycaps API (Recommended)":
53
+ ApiKeyService.set(key)
54
+ elif key_type == "OpenAI API":
55
+ os.environ["PYCAPS_OPENAI_API_KEY"] = key
56
+
57
+ def cleanup_api_keys():
58
+ if ApiKeyService.has():
59
+ ApiKeyService.remove()
60
+ if "PYCAPS_OPENAI_API_KEY" in os.environ:
61
+ del os.environ["PYCAPS_OPENAI_API_KEY"]
62
+
63
+ # --- State Management ---
64
+
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]
79
+
80
+ go_to_step(1)
81
+
82
+ def initialize_session_state():
83
+ defaults = {
84
+ 'current_step': 1,
85
+ 'video_path': None,
86
+ 'transcribed_doc': None,
87
+ 'processed_doc': None,
88
+ 'edit_requested': False,
89
+ 'final_video_path': None,
90
+ 'session_id': str(uuid.uuid4()),
91
+ 'selected_template': None,
92
+ 'previews': {},
93
+ 'preview_generating': False,
94
+ 'lock_file_path': None,
95
+ 'error_message': None,
96
+ }
97
+ for key, value in defaults.items():
98
+ if key not in st.session_state:
99
+ st.session_state[key] = value
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}"
110
+ if st.session_state.lock_file_path:
111
+ release_lock_slot(st.session_state.lock_file_path)
112
+ st.session_state.lock_file_path = None
113
+ import traceback
114
+ traceback.print_exc()
115
+ st.rerun()
116
+
117
+ def display_video(video_path):
118
+ with st.container():
119
+ st.video(video_path)
120
+ with open(video_path, "rb") as file:
121
+ video_bytes = file.read()
122
+ st.download_button(
123
+ "⬇️ Download Video", video_bytes,
124
+ f"pycaps_{Path(video_path).stem}.mp4", "video/mp4"
125
+ )