Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ public-endpoint, иначе `409`.
"media": {"kind": "<media_kind>", "start": "<start>", "end": "<end>",
"key": "results/<id>/output/<media_kind>/NN-slug.ext"}, // полный ключ MinIO
"slide_keys": ["results/<id>/output/slides/slide-NN.png", "..."], // полные ключи MinIO
"slide_nums": [1, 2], // глобальные номера кадров, выравнены со slide_keys
"content_md": "<markdown>"} // markdown, не html
]}
]}
Expand All @@ -90,6 +91,14 @@ public-endpoint, иначе `409`.
напрямую (или через presigned). Секция без медиа -> `media: null`; без слайдов ->
`slide_keys: []`. `source.duration` — `end` последней секции (или `null`, если секций нет).

**Позиция кадра внутри текста.** Для кадров, извлечённых из видео, ядро вставляет
в `content_md` HTML-маркеры `<!-- slide:N -->` между абзацами — в месте, где о кадре
идёт речь (N — номер из `slide_nums`, по нему берётся URL из `slide_keys` той же
позиции). Платформа с поддержкой маркеров режет `content_md` по ним и вставляет
кадры инлайн; без поддержки — маркеры невидимы после markdown-рендера (HTML-коммент),
поведение прежнее (галерея по `slide_keys`). Конспекты без маркеров (старые данные,
документные слайды) рендерятся галереей, как раньше.

**Делёж ядро/платформа.** Ядро отдаёт нейтральное дерево + объекты; рендеринг,
навигацию и собственное представление строит платформа. Способы забрать результат —
см. «Два способа забрать результат».
Expand Down
4 changes: 4 additions & 0 deletions lecturelog/application/pipeline_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from lecturelog.infrastructure.export.structure import build_structure, result_key
from lecturelog.infrastructure.export.zip_utils import zip_dir
from lecturelog.infrastructure.frames.binding import bind_frames_to_sections
from lecturelog.infrastructure.frames.placement import place_slides_in_sections

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -282,6 +283,9 @@ async def structurize_progress(pct: int):
if video_frames:
# G: привязка кадров к секциям по таймкодам + монотонизация
bind_frames_to_sections(video_frames, topics)
# Маркеры <!-- slide:N --> внутри content секций — позиция
# кадра между абзацами (взвешенная пропорция по timestamp)
place_slides_in_sections(video_frames, topics)
slide_items = video_frames

sections = [s for t in topics for s in t.sections]
Expand Down
20 changes: 15 additions & 5 deletions lecturelog/infrastructure/export/obsidian_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,25 @@ async def export(
lines.append(f"![[{media_rel}]]")
lines.append("")

# Кадры с маркером <!-- slide:N --> встают инлайн в текст;
# без маркера (документные слайды, старые данные) — блоком
# перед контентом, как раньше.
content = section.content
for slide_idx in section.slide_indices:
pos = slide_idx - 1
if 0 <= pos < len(slide_targets):
rel = slide_targets[pos].relative_to(output_root).as_posix()
alt = slide_images[pos].caption or f"Слайд {slide_idx}"
lines.append(f"![{alt}]({rel})")
if not 0 <= pos < len(slide_targets):
continue
rel = slide_targets[pos].relative_to(output_root).as_posix()
alt = slide_images[pos].caption or f"Слайд {slide_idx}"
image_line = f"![{alt}]({rel})"
marker = f"<!-- slide:{slide_idx} -->"
if marker in content:
content = content.replace(marker, image_line)
else:
lines.append(image_line)
lines.append("")

lines.append(section.content.strip())
lines.append(content.strip())
lines.append("")

global_section_idx += 1
Expand Down
5 changes: 5 additions & 0 deletions lecturelog/infrastructure/export/structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,22 @@ def build_structure(
"key": result_key(media_targets[global_idx], output_root, task_id),
}

# slide_nums[i] соответствует slide_keys[i] — глобальный номер кадра,
# тот же N, что в маркерах <!-- slide:N --> внутри content_md.
slide_keys: list[str] = []
slide_nums: list[int] = []
for slide_idx in section.slide_indices:
pos = slide_idx - 1 # slide_indices 1-based
if 0 <= pos < len(slide_targets):
slide_keys.append(result_key(slide_targets[pos], output_root, task_id))
slide_nums.append(slide_idx)

subtopics.append(
{
"title": section.title,
"media": media,
"slide_keys": slide_keys,
"slide_nums": slide_nums,
"content_md": section.content,
}
)
Expand Down
89 changes: 89 additions & 0 deletions lecturelog/infrastructure/frames/placement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Расстановка маркеров слайдов внутри текста секций (дизайн 2026-07-07).

Позиция кадра в content вычисляется детерминированно, без LLM: интервал
секции распределяется по абзацам пропорционально их длине в символах
(время абзаца ~ его доля текста), кадр по своему timestamp попадает
в абзац, после которого вставляется маркер ``<!-- slide:N -->``.

N — тот же глобальный 1-based номер кадра, что и в Section.slide_indices.
Web режет content_md по маркерам; старые рендеры их не видят (HTML-коммент).
"""

from __future__ import annotations

import bisect

from lecturelog.domain.models import Topic
from lecturelog.domain.ports import SlideImage
from lecturelog.infrastructure.srt import parse_srt_time

MARKER_TEMPLATE = "<!-- slide:{n} -->"


def split_paragraphs(content: str) -> list[str]:
"""Разбить markdown на блоки верхнего уровня по пустым строкам.

Код-фенсы (```...```) не рвутся, даже если внутри пустые строки.
Единственная реализация сегментации в системе — web о ней не знает,
он режет готовые маркеры."""
blocks: list[str] = []
current: list[str] = []
in_fence = False
for line in content.splitlines():
if line.lstrip().startswith("```"):
in_fence = not in_fence
current.append(line)
continue
if not line.strip() and not in_fence:
if current:
blocks.append("\n".join(current))
current = []
continue
current.append(line)
if current:
blocks.append("\n".join(current))
return blocks


def place_slides_in_sections(items: list[SlideImage], topics: list[Topic]) -> None:
"""Вставить маркеры кадров в content секций по slide_indices и timestamp.

Вызывается после bind_frames_to_sections: slide_indices уже проставлены,
номер N = позиция кадра в items, отсортированных по timestamp (та же
нумерация, что в binding)."""
ordered = sorted(items, key=lambda x: x.timestamp or 0.0)
for topic in topics:
for section in topic.sections:
if not section.slide_indices:
continue
paragraphs = split_paragraphs(section.content)
# Кумулятивные границы абзацев на временной шкале секции,
# взвешенные длиной абзаца в символах.
start = parse_srt_time(section.start)
end = parse_srt_time(section.end)
total_chars = sum(len(p) for p in paragraphs)
bounds: list[float] = [] # конец интервала каждого абзаца
if paragraphs and total_chars > 0 and end > start:
acc = 0
for p in paragraphs:
acc += len(p)
bounds.append(start + (end - start) * acc / total_chars)

# Маркеры после абзаца: after[i] — номера кадров после i-го абзаца
after: dict[int, list[int]] = {}
for n in section.slide_indices:
ts = ordered[n - 1].timestamp or 0.0
# Первый абзац, чей интервал ещё не закончился к ts;
# ts за концом секции (монотонизация) -> последний абзац.
idx = bisect.bisect_right(bounds, ts) if bounds else 0
idx = min(idx, len(paragraphs) - 1) if paragraphs else -1
after.setdefault(idx, []).append(n)

pieces: list[str] = []
if not paragraphs:
pieces = [MARKER_TEMPLATE.format(n=n) for n in after.get(-1, [])]
else:
for i, p in enumerate(paragraphs):
pieces.append(p)
pieces.extend(MARKER_TEMPLATE.format(n=n) for n in after.get(i, []))
section.content = "\n\n".join(pieces)
113 changes: 113 additions & 0 deletions tests/unit/frames/test_placement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Тесты placement: сегментация markdown на абзацы и расстановка маркеров слайдов."""

from pathlib import Path

from lecturelog.domain.models import Section, Topic
from lecturelog.domain.ports import SlideImage
from lecturelog.infrastructure.frames.placement import place_slides_in_sections, split_paragraphs

# --- split_paragraphs ---


def test_split_by_blank_lines():
md = "Первый абзац.\n\nВторой абзац.\n\nТретий."
assert split_paragraphs(md) == ["Первый абзац.", "Второй абзац.", "Третий."]


def test_code_fence_with_blank_lines_not_split():
md = "Текст.\n\n```python\nx = 1\n\ny = 2\n```\n\nПосле."
parts = split_paragraphs(md)
assert parts == ["Текст.", "```python\nx = 1\n\ny = 2\n```", "После."]


def test_multiple_blank_lines_collapse():
md = "Один.\n\n\n\nДва."
assert split_paragraphs(md) == ["Один.", "Два."]


def test_empty_content():
assert split_paragraphs("") == []
assert split_paragraphs("\n\n") == []


# --- place_slides_in_sections ---


def _img(ts):
return SlideImage(path=Path(f"f{ts}.jpg"), timestamp=float(ts))


def _topic(sections):
return Topic(title="Тема", start=sections[0].start, end=sections[-1].end, sections=sections)


def test_marker_lands_after_paragraph_by_timestamp():
# Секция 03:00–06:00 (180 c), 3 равных абзаца по 60 с; слайд ts=250 -> 2-й абзац
sec = Section(
title="A",
start="00:03:00",
end="00:06:00",
content="Абзац раз.\n\nАбзац два.\n\nАбзац три.",
slide_indices=[1],
)
topics = [_topic([sec])]
place_slides_in_sections([_img(250)], topics)
assert sec.content == "Абзац раз.\n\nАбзац два.\n\n<!-- slide:1 -->\n\nАбзац три."


def test_weighting_by_paragraph_length():
# Первый абзац в 9 раз длиннее второго: занимает 90% времени секции.
# Секция 0–100 c, слайд ts=50 попадает в первый абзац (при равномерной
# пропорции попал бы во второй).
long_par = "х" * 900
sec = Section(
title="A",
start="00:00:00",
end="00:01:40",
content=f"{long_par}\n\nкороткий",
slide_indices=[1],
)
topics = [_topic([sec])]
place_slides_in_sections([_img(50)], topics)
assert sec.content == f"{long_par}\n\n<!-- slide:1 -->\n\nкороткий"


def test_section_without_slides_untouched():
sec = Section(title="A", start="00:00:00", end="00:01:00", content="Текст.\n\nЕщё.")
topics = [_topic([sec])]
place_slides_in_sections([_img(10)], topics)
assert sec.content == "Текст.\n\nЕщё."


def test_ts_outside_section_goes_after_last_paragraph():
# Кадр прижат монотонизацией: ts=10 при секции 05:00–06:00 -> в конец
sec = Section(
title="A",
start="00:05:00",
end="00:06:00",
content="Один.\n\nДва.",
slide_indices=[1],
)
topics = [_topic([sec])]
place_slides_in_sections([_img(9999)], topics)
assert sec.content == "Один.\n\nДва.\n\n<!-- slide:1 -->"


def test_two_slides_same_paragraph_keep_ts_order():
sec = Section(
title="A",
start="00:00:00",
end="00:03:00",
content="Один.\n\nДва.\n\nТри.",
slide_indices=[1, 2],
)
topics = [_topic([sec])]
place_slides_in_sections([_img(70), _img(80)], topics)
assert sec.content == "Один.\n\nДва.\n\n<!-- slide:1 -->\n\n<!-- slide:2 -->\n\nТри."


def test_empty_content_gets_markers_only():
sec = Section(title="A", start="00:00:00", end="00:01:00", content="", slide_indices=[1])
topics = [_topic([sec])]
place_slides_in_sections([_img(30)], topics)
assert sec.content == "<!-- slide:1 -->"
53 changes: 53 additions & 0 deletions tests/unit/test_obsidian_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,56 @@ async def test_export_lays_out_output_dir_and_returns_targets(tmp_path):
assert result.slide_targets[0].name == "slide-01.png"
# result.zip больше НЕ создаётся.
assert not (output_dir / "result.zip").exists()


async def test_export_replaces_slide_markers_inline(tmp_path):
# Маркер <!-- slide:N --> в content заменяется картинкой на месте,
# блока слайдов перед текстом при этом нет.
frag = tmp_path / "f1.mp3"
frag.write_bytes(b"audio")
slide = tmp_path / "s1.png"
slide.write_bytes(b"png")
sec = Section(
title="Введение",
start="0:00",
end="5:00",
content="Абзац раз.\n\n<!-- slide:1 -->\n\nАбзац два.",
slide_indices=[1],
)
topic = Topic(title="Тема", start="0:00", end="5:00", sections=[sec], slide_indices=[1])

exporter = ObsidianExporter()
result = await exporter.export(
topics=[topic],
media_fragments=[frag],
slide_images=[SlideImage(path=slide, timestamp=10.0, caption="Заставка")],
output_dir=tmp_path / "export",
media_kind="audio",
)
md = (result.output_root / "конспект.md").read_text(encoding="utf-8")
assert "<!-- slide:1 -->" not in md
assert "Абзац раз.\n\n![Заставка](slides/slide-01.png)\n\nАбзац два." in md
# Ровно одно вхождение картинки — нет дубля блоком перед текстом.
assert md.count("slides/slide-01.png") == 1


async def test_export_slides_without_marker_fall_back_to_block(tmp_path):
# Кадр привязан к секции, но маркера в тексте нет (старое поведение,
# документные слайды) -> блок перед контентом, как раньше.
frag = tmp_path / "f1.mp3"
frag.write_bytes(b"audio")
slide = tmp_path / "s1.png"
slide.write_bytes(b"png")
sec = Section(title="В", start="0:00", end="5:00", content="Текст.", slide_indices=[1])
topic = Topic(title="Т", start="0:00", end="5:00", sections=[sec], slide_indices=[1])

exporter = ObsidianExporter()
result = await exporter.export(
topics=[topic],
media_fragments=[frag],
slide_images=[SlideImage(path=slide)],
output_dir=tmp_path / "export",
media_kind="audio",
)
md = (result.output_root / "конспект.md").read_text(encoding="utf-8")
assert "![Слайд 1](slides/slide-01.png)\n\nТекст." in md
39 changes: 39 additions & 0 deletions tests/unit/test_pipeline_service_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,3 +348,42 @@ def factory(video_path, srt_path):
assert structurizer.slide_images_arg == [doc_item.path]
seen = [stage for stage, _ in repo.stages]
assert PipelineStage.VIDEO_SLIDES not in seen


@pytest.mark.asyncio
async def test_video_frames_markers_placed_into_section_content(tmp_path):
# После привязки кадров пайплайн расставляет маркеры <!-- slide:N -->
# внутри content секции (placement, дизайн 2026-07-07).
repo = InMemoryRepo()
task = Task(task_id="v6", source_kind="video_file")
await repo.create(task)
sec = Section(
title="s",
start="0:00",
end="5:00",
content="Абзац раз.\n\nАбзац два.",
slide_indices=[],
)
topics = [Topic(title="T", start="0:00", end="5:00", sections=[sec], slide_indices=[])]

# ts=280 из 300 c -> второй абзац
frame = SlideImage(path=Path("/work/frames/f1.jpg"), timestamp=280.0, caption="Слайд")
service = _service(
repo,
FakeIngestor(),
FakeTranscriber(),
FakeStructurizer(topics),
RecordingCutter("audio"),
RecordingCutter("video"),
FakeExporter(),
)
await service.run(
task=task,
source=VideoFileSource(path=tmp_path / "v.mp4"),
slide_provider=None,
work_dir=tmp_path,
video_slide_provider_factory=lambda v, s: FakeFrameProvider(frames=[frame]),
)

assert (await repo.get("v6")).status == TaskStatus.DONE
assert sec.content == "Абзац раз.\n\nАбзац два.\n\n<!-- slide:1 -->"
Loading
Loading