From 28b08deac3af00edefbce8d7d444a946d0974bf3 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Tue, 7 Jul 2026 15:33:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(frames):=20=D0=BC=D0=B0=D1=80=D0=BA=D0=B5?= =?UTF-8?q?=D1=80=D1=8B=20=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=81=D0=BB=D0=B0=D0=B9=D0=B4=D0=BE=D0=B2=20=D0=B2=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D1=80=D0=B8=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20?= =?UTF-8?q?=D1=81=D0=B5=D0=BA=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кадр встаёт между абзацами, где о нём идёт речь: позиция считается детерминированно (интервал секции делится по абзацам пропорционально их длине, кадр попадает по timestamp), в content вставляется . structure.json получает slide_nums (выравнены со slide_keys) для сопоставления маркеров с URL на стороне платформы. Обсидиан-экспортёр заменяет маркеры на картинки инлайн; кадры без маркера — блоком перед текстом, как раньше. --- docs/api-contract.md | 9 ++ lecturelog/application/pipeline_service.py | 4 + .../export/obsidian_exporter.py | 20 +++- lecturelog/infrastructure/export/structure.py | 5 + lecturelog/infrastructure/frames/placement.py | 89 ++++++++++++++ tests/unit/frames/test_placement.py | 113 ++++++++++++++++++ tests/unit/test_obsidian_exporter.py | 53 ++++++++ tests/unit/test_pipeline_service_video.py | 39 ++++++ tests/unit/test_structure.py | 33 +++++ 9 files changed, 360 insertions(+), 5 deletions(-) create mode 100644 lecturelog/infrastructure/frames/placement.py create mode 100644 tests/unit/frames/test_placement.py diff --git a/docs/api-contract.md b/docs/api-contract.md index 00b6daa..068a0ff 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -79,6 +79,7 @@ public-endpoint, иначе `409`. "media": {"kind": "", "start": "", "end": "", "key": "results//output//NN-slug.ext"}, // полный ключ MinIO "slide_keys": ["results//output/slides/slide-NN.png", "..."], // полные ключи MinIO + "slide_nums": [1, 2], // глобальные номера кадров, выравнены со slide_keys "content_md": ""} // markdown, не html ]} ]} @@ -90,6 +91,14 @@ public-endpoint, иначе `409`. напрямую (или через presigned). Секция без медиа -> `media: null`; без слайдов -> `slide_keys: []`. `source.duration` — `end` последней секции (или `null`, если секций нет). +**Позиция кадра внутри текста.** Для кадров, извлечённых из видео, ядро вставляет +в `content_md` HTML-маркеры `` между абзацами — в месте, где о кадре +идёт речь (N — номер из `slide_nums`, по нему берётся URL из `slide_keys` той же +позиции). Платформа с поддержкой маркеров режет `content_md` по ним и вставляет +кадры инлайн; без поддержки — маркеры невидимы после markdown-рендера (HTML-коммент), +поведение прежнее (галерея по `slide_keys`). Конспекты без маркеров (старые данные, +документные слайды) рендерятся галереей, как раньше. + **Делёж ядро/платформа.** Ядро отдаёт нейтральное дерево + объекты; рендеринг, навигацию и собственное представление строит платформа. Способы забрать результат — см. «Два способа забрать результат». diff --git a/lecturelog/application/pipeline_service.py b/lecturelog/application/pipeline_service.py index ab9e0a3..1f0aae5 100644 --- a/lecturelog/application/pipeline_service.py +++ b/lecturelog/application/pipeline_service.py @@ -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__) @@ -282,6 +283,9 @@ async def structurize_progress(pct: int): if video_frames: # G: привязка кадров к секциям по таймкодам + монотонизация bind_frames_to_sections(video_frames, topics) + # Маркеры внутри content секций — позиция + # кадра между абзацами (взвешенная пропорция по timestamp) + place_slides_in_sections(video_frames, topics) slide_items = video_frames sections = [s for t in topics for s in t.sections] diff --git a/lecturelog/infrastructure/export/obsidian_exporter.py b/lecturelog/infrastructure/export/obsidian_exporter.py index 743d308..c3b5ab5 100644 --- a/lecturelog/infrastructure/export/obsidian_exporter.py +++ b/lecturelog/infrastructure/export/obsidian_exporter.py @@ -106,15 +106,25 @@ async def export( lines.append(f"![[{media_rel}]]") lines.append("") + # Кадры с маркером встают инлайн в текст; + # без маркера (документные слайды, старые данные) — блоком + # перед контентом, как раньше. + 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"" + 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 diff --git a/lecturelog/infrastructure/export/structure.py b/lecturelog/infrastructure/export/structure.py index fe5078f..544e3c0 100644 --- a/lecturelog/infrastructure/export/structure.py +++ b/lecturelog/infrastructure/export/structure.py @@ -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, что в маркерах внутри 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, } ) diff --git a/lecturelog/infrastructure/frames/placement.py b/lecturelog/infrastructure/frames/placement.py new file mode 100644 index 0000000..8bfd2a4 --- /dev/null +++ b/lecturelog/infrastructure/frames/placement.py @@ -0,0 +1,89 @@ +"""Расстановка маркеров слайдов внутри текста секций (дизайн 2026-07-07). + +Позиция кадра в content вычисляется детерминированно, без LLM: интервал +секции распределяется по абзацам пропорционально их длине в символах +(время абзаца ~ его доля текста), кадр по своему timestamp попадает +в абзац, после которого вставляется маркер ````. + +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 = "" + + +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) diff --git a/tests/unit/frames/test_placement.py b/tests/unit/frames/test_placement.py new file mode 100644 index 0000000..f977dd3 --- /dev/null +++ b/tests/unit/frames/test_placement.py @@ -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\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\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" + + +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\n\n\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 == "" diff --git a/tests/unit/test_obsidian_exporter.py b/tests/unit/test_obsidian_exporter.py index c4c97ce..d635fb9 100644 --- a/tests/unit/test_obsidian_exporter.py +++ b/tests/unit/test_obsidian_exporter.py @@ -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): + # Маркер в 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\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 "" 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 diff --git a/tests/unit/test_pipeline_service_video.py b/tests/unit/test_pipeline_service_video.py index 1a6d8d7..3017cb9 100644 --- a/tests/unit/test_pipeline_service_video.py +++ b/tests/unit/test_pipeline_service_video.py @@ -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): + # После привязки кадров пайплайн расставляет маркеры + # внутри 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" diff --git a/tests/unit/test_structure.py b/tests/unit/test_structure.py index 308e0a4..0b5f691 100644 --- a/tests/unit/test_structure.py +++ b/tests/unit/test_structure.py @@ -158,3 +158,36 @@ def test_build_structure_slide_index_out_of_range_skipped(tmp_path): assert tree["sections"][0]["subtopics"][0]["slide_keys"] == [ "results/s4/output/slides/slide-01.png" ] + # slide_nums выравнены с slide_keys: 99 тоже пропущен. + assert tree["sections"][0]["subtopics"][0]["slide_nums"] == [1] + + +def test_build_structure_slide_nums_parallel_to_keys(tmp_path): + # slide_nums[i] — глобальный номер кадра из маркеров , + # соответствует slide_keys[i]; web сопоставляет маркер с URL по нему. + output_root = tmp_path / "output" + media_targets, slide_targets = _targets(output_root, "audio", 1, 3) + topics = [ + Topic( + title="Т", + start="0", + end="1", + sections=[ + Section(title="С", start="0", end="1", content="c", slide_indices=[2, 3]), + ], + ), + ] + tree = build_structure( + topics=topics, + media_targets=media_targets, + slide_targets=slide_targets, + output_root=output_root, + task_id="s5", + media_kind="audio", + ) + st = tree["sections"][0]["subtopics"][0] + assert st["slide_nums"] == [2, 3] + assert st["slide_keys"] == [ + "results/s5/output/slides/slide-02.png", + "results/s5/output/slides/slide-03.png", + ]