From de0ad9720cdc16c92abd1cbfb8eb4e1d538dfb09 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Mon, 26 Oct 2026 19:49:39 -0400 Subject: [PATCH] [docs] Overhaul documentation for #589 The documentation was in dire need of some improvements as the volume of features PySceneDetect supports has grown. This splits out the CLI and API docs as separate entities, while making it easier to switch between the two. It also improves the ability to find config file docs and sections. --- .github/workflows/check-docs.yml | 10 +- .github/workflows/generate-docs.yml | 9 +- docs/_ext/section_sidebar.py | 265 +++++ docs/_static/pyscenedetect.css | 160 +++ docs/_templates/navigation.html | 23 + docs/api.rst | 51 +- docs/api/backends.rst | 60 +- docs/api/common.rst | 37 + docs/api/detector.rst | 6 + docs/api/detector_options/detect-adaptive.inc | 44 + docs/api/detector_options/detect-content.inc | 40 + docs/api/detector_options/detect-hash.inc | 32 + docs/api/detector_options/detect-hist.inc | 28 + .../api/detector_options/detect-threshold.inc | 44 + docs/api/detectors.rst | 43 +- docs/api/migration_guide.rst | 63 +- docs/api/output.rst | 56 + docs/api/platform.rst | 66 ++ docs/api/scene_manager.rst | 35 + docs/api/stats_manager.rst | 20 + docs/api/video_stream.rst | 16 + docs/cli.rst | 271 +++-- docs/cli/backends.rst | 16 +- docs/cli/config_file.rst | 13 +- docs/cli/config_sections.inc | 969 ++++++++++++++++++ docs/cli_index.inc | 11 + docs/conf.py | 39 + docs/examples.rst | 226 ++++ docs/generate_cli_docs.py | 925 ++++++++++++++++- docs/index.rst | 100 +- scenedetect.cfg | 29 +- scenedetect/_cli/config.py | 22 +- scenedetect/backends/__init__.py | 10 +- scenedetect/scene_manager.py | 10 +- website/pages/changelog.md | 1 + 35 files changed, 3513 insertions(+), 237 deletions(-) create mode 100644 docs/_ext/section_sidebar.py create mode 100644 docs/api/detector_options/detect-adaptive.inc create mode 100644 docs/api/detector_options/detect-content.inc create mode 100644 docs/api/detector_options/detect-hash.inc create mode 100644 docs/api/detector_options/detect-hist.inc create mode 100644 docs/api/detector_options/detect-threshold.inc create mode 100644 docs/cli/config_sections.inc create mode 100644 docs/cli_index.inc create mode 100644 docs/examples.rst diff --git a/.github/workflows/check-docs.yml b/.github/workflows/check-docs.yml index f3124a86..be9f5d55 100644 --- a/.github/workflows/check-docs.yml +++ b/.github/workflows/check-docs.yml @@ -7,11 +7,13 @@ on: paths: - docs/** - scenedetect/** + - scenedetect.cfg - website/** push: paths: - docs/** - scenedetect/** + - scenedetect.cfg - website/** branches: - main @@ -46,9 +48,11 @@ jobs: - name: Check CLI Documentation shell: bash run: | - if [[ `git status --porcelain=1 | wc -l` -ne 0 ]]; then - echo "CLI documentation is of date: docs/cli.rst does not match output after running docs/generate_cli_docs.py!" - echo "Re-run `python docs/generate_cli_docs.py` to update and commit the result." + python docs/generate_cli_docs.py + if [[ $(git status --porcelain=1 -- docs | wc -l) -ne 0 ]]; then + git --no-pager diff -- docs + echo 'CLI documentation is out of date: the generated docs do not match the output of docs/generate_cli_docs.py!' + echo 'Re-run "python docs/generate_cli_docs.py" and commit the result.' exit 1 fi diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 63cd9fdc..225da7d3 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -8,6 +8,7 @@ on: - 'releases/**' # docs/** paths: - 'docs/**' + - 'scenedetect.cfg' workflow_dispatch: jobs: @@ -59,9 +60,11 @@ jobs: - name: Check CLI Documentation shell: bash run: | - if [[ `git status --porcelain=1 | wc -l` -ne 0 ]]; then - echo "CLI documentation is of date: docs/cli.rst does not match output after running docs/generate_cli_docs.py!" - echo "Re-run `python docs/generate_cli_docs.py` to update and commit the result." + python docs/generate_cli_docs.py + if [[ $(git status --porcelain=1 -- docs | wc -l) -ne 0 ]]; then + git --no-pager diff -- docs + echo 'CLI documentation is out of date: the generated docs do not match the output of docs/generate_cli_docs.py!' + echo 'Re-run "python docs/generate_cli_docs.py" and commit the result.' exit 1 fi diff --git a/docs/_ext/section_sidebar.py b/docs/_ext/section_sidebar.py new file mode 100644 index 00000000..32aebba3 --- /dev/null +++ b/docs/_ext/section_sidebar.py @@ -0,0 +1,265 @@ +# Copyright (C) 2026 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +"""Sphinx extension: show only the current section (e.g. CLI or API) in the sidebar. + +The root document has one named toctree per section. For every page inside a section, the +sidebar template gets ``section_toctree()``, which renders that section's toctree fully +expanded (other pages to the section's ``maxdepth``, the current page to ``current_maxdepth``), +plus ``sidebar_section`` and ``sidebar_sections`` for a switch between sections. The root +document itself renders every labelled section's tree, each pruned to +``section_sidebar_root_depth`` (by default the section's ``maxdepth``). Pages in no section +(genindex, search, py-modindex) and the singlehtml builder fall back to the regular global +toctree. + +Configure with ``section_sidebar_sections`` in conf.py:: + + section_sidebar_sections = { + "clitoc": ("CLI", "cli", 2, 2), # toctree name: (label, landing doc, depth, cur depth) + "reftoc": (None, None, 2, 2), # no label: every page in the section is a small link + } + section_sidebar_root_depth = {"apitoc": 1} # on the root document: page titles only + +A section whose label is ``None`` is not part of the switch; the template gets its pages as +``sidebar_section_docs[name]`` (a list of ``(title, docname)``) and draws them as a row of +smaller links. Its tree is only drawn on its own pages, and then holds just the current page's +headings, since the row already lists the pages. + +``section_sidebar_summary`` trims what a page contributes to the tree when it is NOT the +current page: only entries (at any depth) whose text matches one of the glob patterns are +kept, together with the headings above them; an empty list keeps just the page title. +``section_sidebar_page_depth`` overrides the section's current-page depth for one page:: + + section_sidebar_summary = { + "api/backends": ["VideoStream*"], # glob on the entry text + "api/platform": [], # title only + } + section_sidebar_page_depth = {"api/backends": 3} # classes, but not their members +""" + +import fnmatch + +from docutils import nodes +from sphinx import addnodes +from sphinx.application import Sphinx +from sphinx.environment.adapters.toctree import TocTree +from sphinx.util import logging + +logger = logging.getLogger(__name__) + +_LEVEL_PREFIX = "toctree-l" +# Depth the section tree is resolved to before pruning; deeper than any configured depth. +_RESOLVE_DEPTH = 6 + + +def _section_toctree_node(app: Sphinx, pagename: str) -> addnodes.toctree | None: + """Root-doc toctree node whose entries contain `pagename` or one of its ancestors.""" + env = app.env + if pagename not in env.all_docs: + return None + lineage = {pagename, *TocTree(env).get_toctree_ancestors(pagename)} + for node in env.get_doctree(env.config.root_doc).findall(addnodes.toctree): + if lineage.intersection(node["includefiles"]): + return node + return None + + +def _entry_target(item: nodes.list_item) -> str: + """URI a toctree entry links to (page-relative, as rendered), without the anchor.""" + ref = next(item.findall(nodes.reference), None) + return ref.get("refuri", "").split("#", 1)[0] if ref is not None else "" + + +def _drop_sublists(item: nodes.list_item) -> None: + item.children = [c for c in item.children if not isinstance(c, nodes.bullet_list)] + + +def _entry_text(item: nodes.list_item) -> str: + """Text of the entry itself (its first paragraph), not of the entries nested under it.""" + return item[0].astext() if len(item) else "" + + +def _apply_summary(top: nodes.list_item, patterns: list[str]) -> bool: + """Keep only the entries under `top` that match `patterns`, plus the headings above them. + + Returns False if a non-empty pattern list matched nothing. + """ + matches = [ + item + for item in top.findall(nodes.list_item) + if item is not top and any(fnmatch.fnmatchcase(_entry_text(item), p) for p in patterns) + ] + keep: set[int] = set() + for item in matches: + _drop_sublists(item) + node = item + while node is not top: + keep.add(id(node)) + node = node.parent + for item in list(top.findall(nodes.list_item)): + if item is not top and id(item) not in keep and item.parent is not None: + item.parent.remove(item) + for empty in [bl for bl in top.findall(nodes.bullet_list) if not bl.children]: + empty.parent.remove(empty) + return bool(matches) or not patterns + + +def _entry_level(item: nodes.list_item) -> int | None: + return next( + (int(c[len(_LEVEL_PREFIX) :]) for c in item["classes"] if c.startswith(_LEVEL_PREFIX)), + None, + ) + + +def _prune_depth(top: nodes.list_item, maxdepth: int) -> None: + """Drop the sub-lists of every entry under `top` at `maxdepth` or deeper.""" + for item in list(top.findall(nodes.list_item)): + level = _entry_level(item) + if level is not None and level >= maxdepth: + _drop_sublists(item) + + +def _keep_current_headings(tree: nodes.Element) -> None: + """Replace the section's page list with the headings of the current page, one level up. + + For sections without a switch label: the link row under the switch already lists their + pages, so the tree repeats neither the current page's title nor its siblings. + """ + for top in list(tree.findall(nodes.list_item)): + if "toctree-l1" not in top["classes"] or not top.get("iscurrent"): + continue + pages = top.parent + headings = next((c for c in top.children if isinstance(c, nodes.bullet_list)), None) + if headings is None: + pages.parent.remove(pages) + return + for item in headings.findall(nodes.list_item): + level = _entry_level(item) + if level is not None: + item["classes"].remove(f"{_LEVEL_PREFIX}{level}") + item["classes"].append(f"{_LEVEL_PREFIX}{level - 1}") + pages.replace_self(headings) + return + + +def _prune_tree( + tree: nodes.Element, maxdepth: int, current_maxdepth: int, summary: dict[str, list[str]] +) -> None: + """Trim the resolved section tree to the configured depths. + + The current page is cut at `current_maxdepth`. Every other page is cut at `maxdepth`, or, + if `summary` (keyed by rendered URI) has patterns for it, reduced to the matching entries + (none for an empty list) plus the headings above them. + """ + for top in list(tree.findall(nodes.list_item)): + if "toctree-l1" not in top["classes"]: + continue + if top.get("iscurrent"): + _prune_depth(top, current_maxdepth) + continue + patterns = summary.get(_entry_target(top)) + if patterns is None: + _prune_depth(top, maxdepth) + elif not _apply_summary(top, patterns): + logger.warning( + "section_sidebar_summary: no entry of %s matches %r", + _entry_target(top), + patterns, + once=True, + ) + + +def _add_section_toctree(app: Sphinx, pagename: str, templatename, context, doctree) -> None: + sections = app.config.section_sidebar_sections + if app.builder.name == "singlehtml": + # Everything is one page: the switch would link to itself, so hide it. + sections = {} + node = _section_toctree_node(app, pagename) if sections else None + names = node.parent.get("names", []) if node is not None else [] + section = next((n for n in names if n in sections), None) + root_nodes: list[tuple[str, addnodes.toctree]] = [] + if sections and node is None and pagename == app.env.config.root_doc: + for root_node in app.env.get_doctree(pagename).findall(addnodes.toctree): + name = next((n for n in root_node.parent.get("names", []) if n in sections), None) + if name is not None: + root_nodes.append((name, root_node)) + context["sidebar_section"] = section + context["sidebar_sections"] = sections + context["sidebar_section_docs"] = _section_docs(app, sections) if sections else {} + + def render(name: str, toctree_node: addnodes.toctree) -> str: + _, _, maxdepth, current_maxdepth = sections[name] + current_maxdepth = app.config.section_sidebar_page_depth.get(pagename, current_maxdepth) + tree = TocTree(app.env).resolve( + pagename, + app.builder, + toctree_node, + prune=True, + maxdepth=_RESOLVE_DEPTH, + collapse=False, + includehidden=True, + ) + if tree is None: + return "" + # The switch and link row above the tree already name the sections; the toctree + # caption would only repeat them (the captions still head the global toctree used by + # the fallback pages and the singlehtml build). + for child in [c for c in tree.children if isinstance(c, nodes.title)]: + tree.remove(child) + root_depth = app.config.section_sidebar_root_depth + if pagename == app.env.config.root_doc and name in root_depth: + # The root page has no current entry; one depth for every page, no summaries. + _prune_tree(tree, root_depth[name], root_depth[name], {}) + return app.builder.render_partial(tree)["fragment"] + # The resolved tree links by page-relative URI, so key the summary patterns the same way. + summary = { + app.builder.get_relative_uri(pagename, docname): patterns + for docname, patterns in app.config.section_sidebar_summary.items() + } + _prune_tree(tree, maxdepth, current_maxdepth, summary) + if sections[name][0] is None: + _keep_current_headings(tree) + return app.builder.render_partial(tree)["fragment"] + + def section_toctree() -> str: + if section is not None: + return render(section, node) + return "".join( + render(name, root_node) for name, root_node in root_nodes if sections[name][0] + ) + + context["section_toctree"] = section_toctree + + +def _section_docs(app: Sphinx, sections: dict) -> dict[str, list[tuple[str, str]]]: + """(title, docname) of every page listed by each section's toctree, in order.""" + env = app.env + docs: dict[str, list[tuple[str, str]]] = {} + for node in env.get_doctree(env.config.root_doc).findall(addnodes.toctree): + name = next((n for n in node.parent.get("names", []) if n in sections), None) + if name is not None: + docs[name] = [ + (env.titles[d].astext(), d) for d in node["includefiles"] if d in env.titles + ] + return docs + + +def _check_config(app: Sphinx, env) -> None: + """Warn about summary / depth entries that name pages or sections which do not exist.""" + configured = set(app.config.section_sidebar_summary) + configured |= set(app.config.section_sidebar_page_depth) + for docname in sorted(configured - set(env.all_docs)): + logger.warning("section_sidebar: %r is not a document in this project", docname) + sections = set(app.config.section_sidebar_sections) + for name in sorted(set(app.config.section_sidebar_root_depth) - sections): + logger.warning("section_sidebar_root_depth: %r is not a configured section", name) + + +def setup(app: Sphinx) -> dict: + app.add_config_value("section_sidebar_sections", {}, "html", dict) + app.add_config_value("section_sidebar_summary", {}, "html", dict) + app.add_config_value("section_sidebar_page_depth", {}, "html", dict) + app.add_config_value("section_sidebar_root_depth", {}, "html", dict) + app.connect("env-check-consistency", _check_config) + app.connect("html-page-context", _add_section_toctree) + return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/docs/_static/pyscenedetect.css b/docs/_static/pyscenedetect.css index bafe76a4..cf1d0147 100644 --- a/docs/_static/pyscenedetect.css +++ b/docs/_static/pyscenedetect.css @@ -32,3 +32,163 @@ padding-bottom: 12px; padding-top: 12px; } + +/* Per-section sidebar: CLI | Python API switch and the expanded section tree. */ +div.sphinxsidebar nav.section-switch { + font-size: 1.2em; + margin: 0.5em 0 1em 0; +} +/* No captions over the trees (the switch names them); on the index page, where every + section's tree is listed, space separates one tree from the next. */ +div.sphinxsidebarwrapper > ul + ul { + margin-top: 1.2em; +} +div.sphinxsidebar nav.section-switch a.current { + font-weight: bold; + border-bottom: 2px solid #004B6B; +} +div.sphinxsidebar nav.section-switch .sep { + color: #AAA; + margin: 0 0.4em; +} +div.sphinxsidebar ul li.toctree-l3 > a, +div.sphinxsidebar ul li.toctree-l4 > a { + font-size: 0.9em; +} +/* Long constant names (MAX_CONSECUTIVE_DECODE_FAILURES) must wrap inside the sidebar. */ +div.sphinxsidebar code span.pre { + white-space: normal; + overflow-wrap: anywhere; +} + +/* Pages that span both halves (Examples): a row of smaller links under the switch. */ +div.sphinxsidebar nav.section-switch span.section-links { + display: block; + font-size: 0.75em; + margin-top: 0.4em; +} +div.sphinxsidebar nav.section-switch span.section-links::before { + content: "\2192\00a0"; +} +div.sphinxsidebar nav.section-switch span.section-links a { + white-space: nowrap; /* "Migration Guide (v0.7)" wraps as a whole, not at "(v0.7)" */ +} +div.sphinxsidebar nav.section-switch span.section-links a.current { + font-weight: bold; + border-bottom: none; +} + +/* CLI <-> Python API cross-links: compact "See also" boxes with the title inline. */ +div.body div.admonition.seealso { + padding: 8px 14px; + margin: 12px 0; +} +div.body div.admonition.seealso p.admonition-title { + display: inline; + font-size: 1em; + font-weight: bold; + line-height: inherit; + margin: 0 0.4em 0 0; +} +div.body div.admonition.seealso p.admonition-title + p { + display: inline; +} +div.body div.admonition.seealso ul { + margin-top: 0.4em; + margin-bottom: 0; +} + +/* Index page: the Command Line and Python API lists side by side (stacked below alabaster's + breakpoint). */ +div.surfaces { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 2em; + margin: 1em 0; +} +div.surfaces div.surface { + min-width: 0; +} +div.surfaces div.surface p.rubric { + font-size: 1.2em; + margin: 0 0 0.3em 0; +} +div.surfaces div.surface > ul { + margin-left: 1.2em; +} +div.surfaces div.surface li > ul { + margin: 0.1em 0 0.3em 1.4em; +} +div.surfaces li li a { + white-space: nowrap; +} + +/* Generated tables. Per-detector option tables: automatic layout; the option and parameter + names (first two columns) never break, other code may wrap so the Default column takes the + slack; the container scrolls sideways when the table cannot fit (narrow screens, see below). + Config key tables: fixed layout, and code is allowed to wrap because the [split-video] args + default is a long single token. basic.css makes `span.pre` nowrap, so every override has + to name it. */ +table.detector-options, +table.config-keys { + width: 100%; + font-size: 0.9em; +} +div.table-scroll { + overflow-x: auto; +} +div.body table.detector-options code span.pre, +table.config-keys code span.pre { + white-space: normal; + overflow-wrap: anywhere; +} +div.body table.detector-options td:nth-child(-n+2) code span.pre { + white-space: nowrap; + overflow-wrap: normal; +} +table.config-keys { + table-layout: fixed; +} +/* Cross-link boxes: keep each link (e.g. [detect-content]) on one line. */ +div.body div.admonition.seealso a { + white-space: nowrap; +} +/* Long inline code (config file paths) and URLs wrap instead of widening the page. The 980px + bound matches the fluid-width query below. */ +@media screen and (max-width: 980px) { + div.body code.literal span.pre, + div.body a.reference.external { + white-space: normal; + overflow-wrap: anywhere; + } +} + +/* alabaster fixes the page at 940px and only goes fluid below 876px; fill the gap. */ +@media screen and (min-width: 876px) and (max-width: 980px) { + div.document, + div.footer { + width: auto; + margin-left: 20px; + margin-right: 20px; + } +} +@media screen and (max-width: 875px) { + div.surfaces { + grid-template-columns: 1fr; + } + /* The sidebar is drawn as a dark block at the bottom of the page. */ + div.sphinxsidebar code, + div.sphinxsidebar code span.pre { + color: #DDD; + background: transparent; + } + div.sphinxsidebar nav.section-switch a.current { + border-bottom-color: #DDD; + } +} +@media screen and (max-width: 700px) { + /* Too narrow for four columns: keep the table readable and let its container scroll. */ + div.table-scroll table.detector-options { + min-width: 560px; + } +} diff --git a/docs/_templates/navigation.html b/docs/_templates/navigation.html index 4493c591..7da16877 100644 --- a/docs/_templates/navigation.html +++ b/docs/_templates/navigation.html @@ -1,5 +1,28 @@ +{#- Per-section sidebar: `sidebar_section`, `sidebar_sections`, `sidebar_section_docs` and + `section_toctree()` are set by docs/_ext/section_sidebar.py. -#} +{%- if sidebar_sections %} + +{%- endif %} +{%- set section_tree = section_toctree() if sidebar_sections else "" %} +{%- if section_tree %} +{{ section_tree }} +{%- else %}

{{ _('Navigation') }}

{{ toctree(maxdepth=2, includehidden=theme_sidebar_includehidden, collapse=theme_sidebar_collapse) }} +{%- endif %} {% if theme_extra_nav_links %}