Spaces:
Running
Running
Update app_core.py
Browse files- app_core.py +339 -104
app_core.py
CHANGED
|
@@ -1,38 +1,65 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
# или
|
| 10 |
-
import app_core; app_core.generate_video(text)
|
| 11 |
"""
|
| 12 |
|
| 13 |
-
#
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
from pathlib import Path
|
|
|
|
| 16 |
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
#
|
| 19 |
-
|
|
|
|
|
|
|
| 20 |
from openai import OpenAI
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
#
|
| 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 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
-
Source:
|
| 76 |
-
• "raw_text" — full original article
|
| 77 |
-
• "slides" — list of slide dictionaries (title, type, body)
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 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 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
HTML_BASE = """
|
| 95 |
<!DOCTYPE html>
|
| 96 |
<html>
|
| 97 |
<head>
|
| 98 |
-
<meta charset
|
| 99 |
<title>{title}</title>
|
| 100 |
|
| 101 |
<!-- Hyperskill brand-like styling -->
|
| 102 |
-
<link href
|
| 103 |
|
| 104 |
<style>
|
| 105 |
:root {{
|
|
@@ -146,83 +264,104 @@ HTML_BASE = """
|
|
| 146 |
</style>
|
| 147 |
</head>
|
| 148 |
<body>
|
| 149 |
-
<div class
|
| 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 =
|
| 181 |
|
| 182 |
if t == "title":
|
| 183 |
content = f"<h1>{title}</h1>"
|
| 184 |
elif t == "list":
|
| 185 |
-
items = "\n".join(f"<li>{
|
| 186 |
content = f"<h1>{title}</h1><ul>{items}</ul>"
|
| 187 |
elif t == "quote":
|
| 188 |
-
content = f"<blockquote>“{
|
| 189 |
elif t == "code":
|
| 190 |
-
code =
|
| 191 |
content = f"<h1>{title}</h1><pre><code>{code}</code></pre>"
|
| 192 |
-
else:
|
| 193 |
-
content = f"<h1>{title}</h1><p>{
|
| 194 |
|
| 195 |
return HTML_BASE.format(title=title, content=content)
|
| 196 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
-
|
| 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
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
page.goto(f.as_uri())
|
| 214 |
png_path = f.with_suffix(".png")
|
| 215 |
-
page.screenshot(path=png_path)
|
| 216 |
-
|
| 217 |
-
browser.close()
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
|
|
|
|
|
|
|
| 237 |
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|