mariapar commited on
Commit
275d87e
·
verified ·
1 Parent(s): 9a0391f

Update app_core.py

Browse files
Files changed (1) hide show
  1. app_core.py +339 -104
app_core.py CHANGED
@@ -1,38 +1,65 @@
1
  """
2
- Однофайловый конвейер для Gradio:
3
- raw text → outline → HTML+PNG → OpenAI‑TTS → MP4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
- ВАЖНО: PROMPT_JSON и DETAILED_PROMPT оставлены 1‑в‑1.
6
 
7
- Запуск:
8
- python app_core.py # локальный запуск Gradio
9
- # или
10
- import app_core; app_core.generate_video(text)
11
  """
12
 
13
- # стандартные
14
- import os, json, textwrap, subprocess, tempfile, shutil, html, asyncio
 
 
 
 
15
  from pathlib import Path
 
16
  from datetime import datetime
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
- # third‑party
19
- import openai # 1.33.0
 
 
20
  from openai import OpenAI
21
- from pydub import AudioSegment
22
- from playwright.sync_api import sync_playwright
23
- import gradio as gr
24
-
25
- # ─────────────────────────────────────────────────────────────
26
- # 0. Playwright браузер (устанавливаем 1 раз без sudo)
27
- # ─────────────────────────────────────────────────────────────
28
- _pw_flag = Path("/tmp/.pw_chromium_installed")
29
- if not _pw_flag.exists():
30
- subprocess.run(["playwright", "install", "chromium"], check=True)
31
- _pw_flag.touch()
32
-
33
- # ─────────────────────────────────────────────────────────────
34
- # 1. System prompts (оставлены без изменений)
35
- # ─────────────────────────────────────────────────────────────
36
  PROMPT_JSON = textwrap.dedent("""
37
  You are a presentation-outliner.
38
  The user needs VALID json only — no extra commentary. (json!)
@@ -68,38 +95,129 @@ PROMPT_JSON = textwrap.dedent("""
68
 
69
  """).strip()
70
 
71
- DETAILED_PROMPT = textwrap.dedent("""
72
- You are a friendly, motivational voice-over writer.
73
- The user needs VALID json only — no extra commentary. (json!)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
- Source:
76
- • "raw_text" — full original article
77
- • "slides" — list of slide dictionaries (title, type, body)
78
 
79
- Task for EACH slide in order:
80
- Write **at least two sentences** (≈ 25–60 words total).
81
- Use the slide’s visible content **and** extra context from raw_text.
82
- • Keep a welcoming tone: encourage, explain, or add a useful tip.
83
- • Mention code or quote briefly (“In this code snippet you’ll see …”).
84
- • First slide → start with a warm greeting + slide title.
85
- • Last slide → quick recap + short friendly goodbye.
86
 
87
- Output exactly:
88
- { "narration":[ { "slide_idx":N, "voice_text":"..." }, ] }
89
- """).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
91
- # ─────────────────────────────────────────────────────────────
92
- # 2. HTML/CSS шаблон слайдов (без изменений)
93
- # ─────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  HTML_BASE = """
95
  <!DOCTYPE html>
96
  <html>
97
  <head>
98
- <meta charset=\"utf-8\">
99
  <title>{title}</title>
100
 
101
  <!-- Hyperskill brand-like styling -->
102
- <link href=\"https://fonts.googleapis.com/css2?family=PT+Root+UI:wght@400;700&display=swap\" rel=\"stylesheet\">
103
 
104
  <style>
105
  :root {{
@@ -146,83 +264,104 @@ HTML_BASE = """
146
  </style>
147
  </head>
148
  <body>
149
- <div class=\"wrap\">
150
  {content}
151
  </div>
152
  </body>
153
  </html>
154
  """
155
 
156
- # ─────────────────────────────────────────────────────────────
157
- # 3. Функции конвейера
158
- # ─────────────────────────────────────────────────────────────
159
- client = OpenAI() # ключ берётся из OPENAI_API_KEY
160
-
161
-
162
- def text_to_outline(raw_text: str, model: str = "gpt-4o") -> list:
163
- """GPT → список словарей слайдов"""
164
- resp = client.chat.completions.create(
165
- model=model,
166
- temperature=0.3,
167
- response_format={"type": "json_object"},
168
- messages=[
169
- {"role": "system", "content": PROMPT_JSON},
170
- {"role": "user", "content": raw_text}
171
- ],
172
- max_tokens=2048,
173
- )
174
- slides = json.loads(resp.choices[0].message.content)["slides"]
175
- return slides
176
-
177
-
178
  def build_slide_html(slide: dict) -> str:
 
179
  t, body = slide["type"], slide["body"]
180
- title = html.escape(slide["title"])
181
 
182
  if t == "title":
183
  content = f"<h1>{title}</h1>"
184
  elif t == "list":
185
- items = "\n".join(f"<li>{html.escape(str(it))}</li>" for it in body)
186
  content = f"<h1>{title}</h1><ul>{items}</ul>"
187
  elif t == "quote":
188
- content = f"<blockquote>“{html.escape(str(body))}”</blockquote>"
189
  elif t == "code":
190
- code = html.escape(str(body).strip().lstrip("`").rstrip("`"))
191
  content = f"<h1>{title}</h1><pre><code>{code}</code></pre>"
192
- else: # text
193
- content = f"<h1>{title}</h1><p>{html.escape(str(body))}</p>"
194
 
195
  return HTML_BASE.format(title=title, content=content)
196
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
198
- def save_html(slides: list, slides_dir: Path) -> list:
199
- html_paths = []
200
- for s in slides:
201
- f = slides_dir / f"slide_{s['slide_idx']:03}.html"
202
- f.write_text(build_slide_html(s), encoding="utf-8")
203
- html_paths.append(f)
204
- return html_paths
205
 
 
 
206
 
207
- def html_to_png(html_paths: list):
208
- png_paths = []
209
- with sync_playwright() as p:
210
- browser = p.chromium.launch()
211
- page = browser.new_page(viewport={"width":1280, "height":720})
212
- for f in html_paths:
213
- page.goto(f.as_uri())
214
  png_path = f.with_suffix(".png")
215
- page.screenshot(path=png_path)
216
- png_paths.append(png_path)
217
- browser.close()
218
- return png_paths
 
 
 
 
 
 
 
 
219
 
 
 
 
 
 
 
 
 
 
220
 
221
- def generate_narration(raw_text: str, slides: list, model: str = "gpt-4o") -> list:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  resp = client.chat.completions.create(
223
  model=model,
224
  temperature=0.8,
225
- response_format={"type": "json_object"},
226
  messages=[
227
  {"role": "system", "content": DETAILED_PROMPT},
228
  {"role": "user", "content": json.dumps({
@@ -232,14 +371,110 @@ def generate_narration(raw_text: str, slides: list, model: str = "gpt-4o") -> li
232
  ],
233
  max_tokens=2048,
234
  )
235
- return json.loads(resp.choices[0].message.content)["narration"]
 
 
 
 
236
 
 
 
237
 
238
- def tts_narration(narration_list: list, audio_dir: Path):
239
- audio_dir.mkdir(exist_ok=True)
240
- wav_paths, durations = [], []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
- for item in narration_list:
243
- idx, text = item["slide_idx"], item["voice_text"]
244
- speech = client.audio.speech.create(
245
- model="
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ app_core.py
3
+ -----------
4
+ Обёртка для Gradio: функция generate_video(text) возвращает
5
+ путь к MP4. Весь ваш существующий код вставляется внутрь
6
+ комментария # <<< YOUR PIPELINE >>> без изменений.
7
+ """
8
+
9
+ from pathlib import Path
10
+ import tempfile
11
+
12
+ def generate_video(text: str) -> str:
13
+ """
14
+ Главная точка входа для Gradio.
15
+ Принимает сырой текст, запускает ваш скрипт,
16
+ возвращает абсолютный путь к сгенерированному MP4.
17
+ """
18
+ # — создаём рабочую временную папку (не трогайте, если не нужно) —
19
+ work_dir = Path(tempfile.mkdtemp(prefix="ppt2vid_"))
20
+
21
+ # -*- coding: utf-8 -*-
22
+ """AI presentation Generator.ipynb
23
 
24
+ Automatically generated by Colab.
25
 
26
+ Original file is located at
27
+ https://colab.research.google.com/drive/11RrHdQUWEiajChTVrryhcnbc4LUkgWUA
 
 
28
  """
29
 
30
+ # 🐍 0-A. Устанавливаем и импортируем
31
+ !pip install --quiet --upgrade openai playwright
32
+ !playwright install chromium # нужен для «скриншотов» слайдов позже
33
+ !apt-get -y install ffmpeg # для финального рендеринга видео
34
+
35
+ import os, json, textwrap, openai
36
  from pathlib import Path
37
+
38
  from datetime import datetime
39
+ RUN_ID = datetime.now().strftime("%Y%m%d_%H%M%S") # уникальный ID
40
+ BASE_DIR = Path(f"/content/run_{RUN_ID}") #
41
+ SLIDES_DIR = BASE_DIR / "slides"
42
+ AUDIO_DIR = BASE_DIR / "audio"
43
+ SLIDES_DIR.mkdir(parents=True, exist_ok=True)
44
+ AUDIO_DIR.mkdir(parents=True, exist_ok=True)
45
+ print("Current run folder ⇒", BASE_DIR)
46
+
47
+ # 🗝️ 0-B. Ключ OpenAI —
48
+ openai.api_key = os.getenv("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")
49
+
50
+ print("✓ Environment is ready")
51
 
52
+ # 1. Text -> HTML
53
+ !pip install --quiet --upgrade "openai>=1.33.0"
54
+
55
+ import os, json, textwrap
56
  from openai import OpenAI
57
+
58
+ client = OpenAI()
59
+
60
+ os.environ["OPENAI_API_KEY"] = "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"
61
+
62
+ # System prompt
 
 
 
 
 
 
 
 
 
63
  PROMPT_JSON = textwrap.dedent("""
64
  You are a presentation-outliner.
65
  The user needs VALID json only — no extra commentary. (json!)
 
95
 
96
  """).strip()
97
 
98
+ def text_to_outline(raw_text: str,
99
+ model: str = "gpt-4o"):
100
+ """Return structured slide list and print it."""
101
+ resp = client.chat.completions.create(
102
+ model=model,
103
+ temperature=0.3,
104
+ response_format={"type": "json_object"},
105
+ messages=[
106
+ {"role": "system", "content": PROMPT_JSON},
107
+ {"role": "user", "content": raw_text}
108
+ ],
109
+ max_tokens=2048,
110
+ )
111
+ slides = json.loads(resp.choices[0].message.content)["slides"]
112
+ print("=== SLIDE OUTLINE ===")
113
+ print(json.dumps(slides, indent=2, ensure_ascii=False))
114
+ return slides
115
 
 
 
 
116
 
117
+ # ——— DEMO Text
118
+ demo_text = '''
119
+ Programs in which there's nothing to calculate are quite rare. Therefore, learning to program with numbers is never a bad idea. An even more valuable skill we are about to learn is the processing of user data. With its help, you can create interactive and by far more flexible applications. So let's get started!
 
 
 
 
120
 
121
+ Reading numbers from user input
122
+ Since you have become familiar with the input() function in Python, it's hardly new to you that any data passed to this function is treated as a string. But how should we deal with numerical values? As a general rule, they are explicitly converted to corresponding numerical types:
123
+
124
+ integer = int(input())
125
+ floating_point = float(input())
126
+
127
+ Pay attention to current best practices: it's crucial not to name your variables as built-in types (say, float or int). Also, we should take into account user mistakes: if a user types an inaccurate input, say, a string 'two' instead of a number 2, a ValueError will occur. At the moment, we won't focus on it; but don't worry, more information about errors is available in a dedicated topic. Now, consider a more detailed and pragmatic example of handling numerical inputs.
128
+
129
+ Free air miles
130
+ Imagine you have a credit card with a free air miles bonus program (or maybe you already have one). As a user, you are expected to input the amount of money you spend on average from this card per month. Let's assume that the bonus program gives you 2 free air miles for every dollar you spend. Here's a simple program to figure out when you can travel somewhere for free:
131
+
132
+ # the average amount of money per month
133
+ money = int(input("How much money do you spend per month: "))
134
+
135
+ # the number of miles per unit of money
136
+ n_miles = 2
137
+
138
+ # earned miles
139
+ miles_per_month = money * n_miles
140
+
141
+ # the distance between London and Paris
142
+ distance = 215
143
+
144
+ # how many months do you need to get
145
+ # a free trip from London to Paris and back
146
+ print(distance * 2 / miles_per_month)
147
+
148
+ This program will calculate how many months it takes to travel the selected distance and back.
149
 
150
+ Although it is recommended to write messages for users in the input() function, avoid them in our educational programming challenges, otherwise your code may not pass our tests.
151
+ Advanced forms of assignment
152
+ Whenever you use an equal sign =, you actually assign some value to a variable. For that reason, = is typically referred to as an assignment operator. Meanwhile, there are other assignment operators you can use in Python. They are also called compound assignment operators, for they carry out an arithmetic operation and assignment in one step. Have a look at the code snippet below:
153
+
154
+ # simple assignment
155
+ number = 10
156
+ number = number + 1 # 11
157
+
158
+ This code is equivalent to the following one:
159
+
160
+ # compound assignment
161
+ number = 10
162
+ number += 1 # 11
163
+
164
+ One can clearly see from the example that the second piece of code is more concise (for it doesn't repeat the variable's name).
165
+
166
+ Naturally, similar assignment forms exist for the rest of arithmetic operations: -=, *=, /=, //=, %=, **=. Given the opportunity, use them to save time and effort.
167
+
168
+ One possible application of compound assignment comes next.
169
+
170
+ Counter variable
171
+ In programming, there is a concept called loop. It is used to repeat some block of code a certain number of times. Pretty often they have special variables called counters alongside them. A counter, as the name presupposes, counts something: how many times a condition is met, how many elements in the sequence, etc. Hence, counters should be integers. Now we are getting to the point: you can use the operators += and -= to increase or decrease the counter respectively.
172
+
173
+ Consider this example where a user determines the value by which the counter is increased:
174
+
175
+ counter = 1
176
+ step = int(input()) # let it be 3
177
+ counter += step
178
+ print(counter) # it should be 4
179
+
180
+ In case you need only non-negative integers from the user (we are increasing the counter after all!), you can prevent incorrect inputs by using the abs() function. It is a Python built-in function that returns the absolute value of a number (that is, value regardless of its sign). Let's readjust our last program a bit:
181
+
182
+ counter = 1
183
+ step = abs(int(input())) # user types -3
184
+ counter += step
185
+ print(counter) # it's still 4
186
+
187
+ As you can see, thanks to the abs() function we got a positive number.
188
+
189
+ For now, it's all right that you do not know much about the mentioned details of errors, loops, and built-in functions in Python. We will catch up and make sure that you know these topics comprehensively. Keep learning!
190
+ Summary
191
+ Thus, we have shed some light on new details about integer arithmetic and the processing of numerical inputs in Python. Feel free to use them in your future projects. In this topic, we discussed:
192
+
193
+ how to read numbers from the user input;
194
+ how to assign numbers to variables and use arithmetic operators to assign the result of the calculation;
195
+ what counters are and when they are used.
196
+ '''
197
+
198
+ slides = text_to_outline(demo_text)
199
+
200
+ from datetime import datetime
201
+ from pathlib import Path
202
+ import html as _h
203
+
204
+ # 0. Уникальный каталог для каждого запуска
205
+ RUN_ID = datetime.now().strftime("%Y%m%d_%H%M%S")
206
+ BASE_DIR = Path(f"/content/run_{RUN_ID}")
207
+ SLIDES_DIR = BASE_DIR / "slides"
208
+ SLIDES_DIR.mkdir(parents=True, exist_ok=True)
209
+ print("Files will be saved to", SLIDES_DIR)
210
+
211
+ # 1. Мини-шаблон HTML (белый фон, чёрный текст)
212
  HTML_BASE = """
213
  <!DOCTYPE html>
214
  <html>
215
  <head>
216
+ <meta charset="utf-8">
217
  <title>{title}</title>
218
 
219
  <!-- Hyperskill brand-like styling -->
220
+ <link href="https://fonts.googleapis.com/css2?family=PT+Root+UI:wght@400;700&display=swap" rel="stylesheet">
221
 
222
  <style>
223
  :root {{
 
264
  </style>
265
  </head>
266
  <body>
267
+ <div class="wrap">
268
  {content}
269
  </div>
270
  </body>
271
  </html>
272
  """
273
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  def build_slide_html(slide: dict) -> str:
275
+ import html as _h
276
  t, body = slide["type"], slide["body"]
277
+ title = _h.escape(slide["title"])
278
 
279
  if t == "title":
280
  content = f"<h1>{title}</h1>"
281
  elif t == "list":
282
+ items = "\n".join(f"<li>{_h.escape(str(it))}</li>" for it in body)
283
  content = f"<h1>{title}</h1><ul>{items}</ul>"
284
  elif t == "quote":
285
+ content = f"<blockquote>“{_h.escape(str(body))}”</blockquote>"
286
  elif t == "code":
287
+ code = _h.escape(str(body).strip().lstrip("`").rstrip("`"))
288
  content = f"<h1>{title}</h1><pre><code>{code}</code></pre>"
289
+ else: # text
290
+ content = f"<h1>{title}</h1><p>{_h.escape(str(body))}</p>"
291
 
292
  return HTML_BASE.format(title=title, content=content)
293
 
294
+ # 2. Сохраняем HTML-слайды в новую папку
295
+ html_paths = []
296
+ for s in slides:
297
+ f = SLIDES_DIR / f"slide_{s['slide_idx']:03}.html"
298
+ f.write_text(build_slide_html(s), encoding="utf-8")
299
+ html_paths.append(f)
300
+ print("saved →", f.name)
301
+
302
+ print(f"✓ {len(html_paths)} HTML files saved to {SLIDES_DIR}")
303
+
304
+ # ────────────────────────────────────────────────────────────
305
+ # 2. HTML → PNG
306
+ # ──────────────────────────────────��─────────────────────────
307
+ from pathlib import Path
308
+ import html, textwrap
309
 
310
+ from playwright.async_api import async_playwright
 
 
 
 
 
 
311
 
312
+ html_paths = sorted(SLIDES_DIR.glob("slide_*.html"))
313
+ assert html_paths, "❌ No HTML files found — generate them first!"
314
 
315
+ async def render_pngs(paths):
316
+ async with async_playwright() as p:
317
+ browser = await p.chromium.launch()
318
+ page = await browser.new_page(viewport={"width":1280,"height":720})
319
+ for f in paths:
320
+ await page.goto(f.as_uri())
 
321
  png_path = f.with_suffix(".png")
322
+ await page.screenshot(path=png_path)
323
+ print(" →", png_path.name)
324
+ await browser.close()
325
+
326
+ await render_pngs(html_paths) # ← верхнеуровневый await
327
+
328
+ print("✓ PNG generation complete — open /content/slides")
329
+
330
+ # Demo text -> Comments
331
+ import json, textwrap
332
+ from openai import OpenAI
333
+ client = OpenAI() # API-ключ уже задан в окружении
334
 
335
+ raw_text = demo_text
336
+
337
+ DETAILED_PROMPT = textwrap.dedent("""
338
+ You are a friendly, motivational voice-over writer.
339
+ The user needs VALID json only — no extra commentary. (json!)
340
+
341
+ Source:
342
+ • "raw_text" — full original article
343
+ • "slides" — list of slide dictionaries (title, type, body)
344
 
345
+ Task for EACH slide in order:
346
+ • Write **at least two sentences** (≈ 25–60 words total).
347
+ • Use the slide’s visible content **and** extra context from raw_text.
348
+ • Keep a welcoming tone: encourage, explain, or add a useful tip.
349
+ • Mention code or quote briefly (“In this code snippet you’ll see …”).
350
+ • First slide → start with a warm greeting + slide title.
351
+ • Last slide → quick recap + short friendly goodbye.
352
+
353
+ Output exactly:
354
+ { "narration":[ { "slide_idx":N, "voice_text":"..." }, … ] }
355
+ """).strip()
356
+
357
+ def generate_detailed_narration(raw_text: str,
358
+ slides: list,
359
+ model: str = "gpt-4o"):
360
+ """Return narration list; print for review."""
361
  resp = client.chat.completions.create(
362
  model=model,
363
  temperature=0.8,
364
+ response_format={ "type": "json_object" },
365
  messages=[
366
  {"role": "system", "content": DETAILED_PROMPT},
367
  {"role": "user", "content": json.dumps({
 
371
  ],
372
  max_tokens=2048,
373
  )
374
+ narration = json.loads(resp.choices[0].message.content)["narration"]
375
+ print("=== DETAILED NARRATION ===")
376
+ for n in narration:
377
+ print(f"[Slide {n['slide_idx']}] {n['voice_text']}\n")
378
+ return narration
379
 
380
+ # ▸ запускаем с текущими raw_text и slides
381
+ narration_list = generate_detailed_narration(raw_text, slides)
382
 
383
+ # ──────────────────────────────────────────────────────────────
384
+ # 4. Text-to-Speech ➜ per-slide WAV ➜ narration.wav
385
+ # ──────────────────────────────────────────────────────────────
386
+ !pip install --quiet --upgrade --no-cache-dir "openai>=1.33.0"
387
+ !pip install --quiet pydub
388
+
389
+ import importlib, json, os, openai
390
+ openai = importlib.reload(openai)
391
+ print("OpenAI SDK version:", openai.__version__)
392
+
393
+ from openai import OpenAI
394
+ from pathlib import Path
395
+ from pydub import AudioSegment
396
+
397
+ client = OpenAI() # ключ берётся из окружения
398
+
399
+ # — исходные данные
400
+ assert 'narration_list' in globals(), "narration_list not found"
401
+ print(f"{len(narration_list)} slides to voice.")
402
+
403
+ AUDIO_DIR.mkdir(exist_ok=True)
404
+
405
+ wav_paths, durations = [], []
406
+
407
+ for item in narration_list:
408
+ idx, text = item["slide_idx"], item["voice_text"]
409
+ print(f"🔊 Slide {idx}: synthesizing…")
410
+
411
+ # правильный параметр — response_format
412
+ speech = client.audio.speech.create(
413
+ model="tts-1",
414
+ voice="alloy",
415
+ input=text,
416
+ response_format="wav" # ← теперь корректно
417
+ )
418
+
419
+ wav_path = AUDIO_DIR / f"slide_{idx:03}.wav"
420
+ speech.stream_to_file(wav_path)
421
+ wav_paths.append(wav_path)
422
+
423
+ snd = AudioSegment.from_file(wav_path)
424
+ durations.append(round(snd.duration_seconds, 2))
425
+
426
+ print(f"✓ {len(wav_paths)} WAV files saved to {AUDIO_DIR}")
427
+
428
+ # — склейка
429
+ combined = AudioSegment.empty()
430
+ for w in sorted(wav_paths):
431
+ combined += AudioSegment.from_file(w)
432
+
433
+ final_wav = AUDIO_DIR / "narration.wav"
434
+ combined.export(final_wav, format="wav")
435
+ print(f"✓ Combined audio saved as {final_wav}")
436
+
437
+ # --- отчёт ---
438
+ for i, d in enumerate(durations, 1):
439
+ print(f" slide_{i:03}: {d}s")
440
+ print("✓ TTS stage complete — ready for ffmpeg video assembly")
441
+
442
+ # === Ячейка: сборка видео (PNG + narration.wav → MP4) =====================
443
+ import subprocess
444
+ from pathlib import Path
445
 
446
+ # 0) Проверяем входные файлы
447
+ png_files = sorted(SLIDES_DIR.glob("slide_*.png"))
448
+ assert png_files, "❌ PNG slides not found"
449
+ assert (AUDIO_DIR / "narration.wav").exists(), "❌ narration.wav missing"
450
+ assert 'durations' in globals(), "❌ durations[] list not found"
451
+
452
+ # 1) slides.txt для ffmpeg (лежит в той же папке, что PNG)
453
+ concat_file = SLIDES_DIR / "slides.txt"
454
+ with concat_file.open("w") as f:
455
+ for img, dur in zip(png_files, durations):
456
+ f.write(f"file '{img}'\n")
457
+ f.write(f"duration {dur}\n")
458
+ f.write(f"file '{png_files[-1]}'\n") # повторяем последний кадр
459
+
460
+ print("✓ slides.txt created →", concat_file)
461
+
462
+ # 2) Финальный MP4 в папке текущего запуска
463
+ output_mp4 = BASE_DIR / "output.mp4" # ← теперь в BASE_DIR
464
+
465
+ ffmpeg_cmd = [
466
+ "ffmpeg", "-y",
467
+ "-f", "concat", "-safe", "0", "-i", str(concat_file),
468
+ "-i", str(AUDIO_DIR / "narration.wav"),
469
+ "-c:v", "libx264", "-pix_fmt", "yuv420p",
470
+ "-c:a", "aac", "-shortest",
471
+ str(output_mp4)
472
+ ]
473
+
474
+ print("🔧 Running ffmpeg …")
475
+ subprocess.run(ffmpeg_cmd, check=True)
476
+ print("✓ Video saved to", output_mp4)
477
+ # ==========================================================================
478
+
479
+ # ↓↓↓ ничего ниже не трогайте
480
+ return str(output_mp4)