pax_global_header00006660000000000000000000000064152717177700014530gustar00rootroot0000000000000052 comment=cca74e4a6c3277db4eef193daf149cf05b33da49 glut23-webvtt-py-cca74e4/000077500000000000000000000000001527171777000153035ustar00rootroot00000000000000glut23-webvtt-py-cca74e4/.gitattributes000066400000000000000000000002551527171777000202000ustar00rootroot00000000000000# check out the sample files as they are on every platform, so the tests # read the same bytes on Windows, where Git converts line endings by default tests/samples/** -text glut23-webvtt-py-cca74e4/.github/000077500000000000000000000000001527171777000166435ustar00rootroot00000000000000glut23-webvtt-py-cca74e4/.github/pull_request_template.md000066400000000000000000000002701527171777000236030ustar00rootroot00000000000000 - [ ] Added a line under `Unreleased` in `CHANGELOG.rst`, or not needed (docs, CI or tests only). Contributors can leave this for the maintainers. glut23-webvtt-py-cca74e4/.github/workflows/000077500000000000000000000000001527171777000207005ustar00rootroot00000000000000glut23-webvtt-py-cca74e4/.github/workflows/ci.yml000066400000000000000000000031231527171777000220150ustar00rootroot00000000000000name: CI on: push: branches: - master pull_request: branches: - master permissions: contents: read jobs: build: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest] python-version: ['3.10', '3.11', '3.12', '3.13', '3.14', '3.15'] include: # files are written with the line separator of the platform, and # before Python 3.15 open() defaults to the cp1252 encoding - os: windows-latest python-version: '3.12' - os: windows-latest python-version: '3.15' - os: macos-latest python-version: '3.15' steps: - name: Checkout Repository uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} # pip 26.2.2 no longer trusts the certificates of the eMudhra/emSign # CA incident - name: Upgrade pip shell: bash run: python -m pip install --upgrade 'pip>=26.2.2' - name: Install tox run: python -m pip install tox - name: Run tox run: tox - name: Check manpage is up to date if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' run: | pip install -r docs/requirements.txt make man git diff --exit-code -I '^\.TH' man/webvtt.1 - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml glut23-webvtt-py-cca74e4/.gitignore000066400000000000000000000002741527171777000172760ustar00rootroot00000000000000# Compiled files __pycache__/ *.pyc # Distribution *.egg-info .eggs/ build/ dist/ # Testing .tox/ .coverage # PyCharm .idea # Documentation docs/_build/ # Virtual environments .venv/ glut23-webvtt-py-cca74e4/.readthedocs.yaml000066400000000000000000000003171527171777000205330ustar00rootroot00000000000000version: "2" build: os: "ubuntu-22.04" tools: python: "3.12" sphinx: configuration: docs/source/conf.py python: install: - requirements: docs/requirements.txt - method: pip path: .glut23-webvtt-py-cca74e4/CHANGELOG.rst000066400000000000000000000346751527171777000173430ustar00rootroot00000000000000History ======= 0.7.0 (01-11-2026) ------------------ See the upgrade notes at https://webvtt-py.readthedocs.io/en/latest/upgrading.html for what to change in code using the library. Changes that may need updates to the code using the library: * Requires Python 3.10 or later, as Python 3.7, 3.8 and 3.9 are end-of-life (#136, #148) * Strict parsing raises ``MalformedCaptionError`` for cues whose end time is not after the start time, for blocks that are not a cue, comment, style or region, such as text without cue timings, for comments where ``NOTE`` is not followed by a space, a tab or a line break, or with ``-->``, and for styles after the first cue (#97, #133) * Writing WebVTT raises ``ValueError`` for ``-->`` in a comment or in the header text, which WebVTT does not allow (#134, #135) * ``NOTE`` blocks with ``-->`` are not read as comments, and header text with ``-->`` is read as empty, as WebVTT does, so the file can be saved again (#134, #135) * Removed ``Caption.CUE_TEXT_TAGS``, which is no longer used (#139, #153) * ``save``, ``save_as_srt`` and the ``-o`` option of the CLI write a path with an extension as given, instead of adding ``.vtt`` or ``.srt`` to it, so ``captions.webvtt`` is no longer saved as ``captions.webvtt.vtt`` (#144, #156) * ``save`` and ``save_as_srt`` without a path or with a folder, and the ``-o`` option of the CLI with a folder, keep the extension of the file when it is already of the format, so a file read from ``captions.webvtt`` is saved to it instead of ``captions.vtt`` (#159, #195) * ``from_buffer`` raises ``TypeError`` for a string or bytes, instead of ``MalformedFileError``: use ``from_string`` to read a string, or ``io.BytesIO`` to read bytes (#143, #157) * SRT cues without an index are read, as most SRT players do, instead of skipped (#142, #158) * Styles after the first cue and blocks where ``NOTE`` is not followed by a space, a tab or a line break, such as ``NOTEBOOK``, are ignored, as WebVTT does, and strict parsing raises ``MalformedFileError`` when ``WEBVTT`` is not followed by a space, a tab or the end of the first line, such as ``WEBVTTX`` (#141, #194) * ``save`` raises ``ValueError`` for ``add_bom=True`` with an encoding without a Byte Order Mark, such as ``latin-1``, and for ``add_bom=False`` with ``utf-8-sig``, ``utf-16`` or ``utf-32``, which always write one, instead of ignoring ``add_bom`` (#160, #196) * ``save_as_srt`` saves captions read with ``from_srt`` or ``from_buffer`` in SRT format and an ``encoding`` in the same encoding, instead of UTF-8, and ``encoding=None`` saves in UTF-8 instead of the encoding of the locale (#161, #197) * Setting ``Caption.text`` to a value that is not a string or a sequence of lines raises ``TypeError`` instead of ``AttributeError``, and ``Caption`` raises it for lines that are not strings instead of accepting them (#162, #198) * ``WebVTT`` keeps empty lists given as arguments, as it does with other lists, instead of replacing them with new ones, and ``Style`` copies a list of lines, as ``Caption`` does, instead of keeping it (#163, #199) * ``segment`` raises ``FileExistsError``, and the ``segment`` command of the CLI exits with an error, when the output folder has the playlist or segments of an earlier run, instead of writing over them and keeping the segments the new playlist does not have: use ``overwrite=True`` or ``--overwrite`` to replace them (#164, #200) * ``from_buffer`` raises ``ValueError`` for ``strict=True`` with the ``srt`` or ``sbv`` format, which have no strict parsing, instead of ignoring it (#167, #203) Changes: * Added reading binary file-like objects with ``from_buffer``, such as files opened in ``'rb'`` mode, with the encoding of their Byte Order Mark, kept when saving, or the new ``encoding`` argument (#143, #157) * Added setting ``Caption.text`` to a sequence of lines, as when creating a ``Caption`` (#162, #198) * Added ``WebVTTError`` as the base class of the errors, and made ``MalformedFileError`` and ``MalformedCaptionError`` subclasses of ``ValueError`` (#146, #155) * Added ``MalformedRegionError``, a subclass of ``MalformedCaptionError``, raised for region settings that are not valid and, with strict parsing, for regions after the first cue (#168, #204) * Added the ``overwrite`` argument of ``segment`` and the ``--overwrite`` option of the CLI, which replace the files of an earlier segmentation and delete its segments that the new playlist does not have (#164, #200) * Added the ``duration`` argument of ``segment``, ``slice_segments`` and ``write_manifest``, and the ``--duration`` option of the CLI, for the length of the media, so the HLS playlist covers all of it and its last segment ends with it instead of lasting the whole target duration (#165, #201) * Added segmenting a ``WebVTT`` instance with ``segment``, such as captions edited or read from another format, without saving them first, and reading the captions from standard input with ``-`` as the ``PATH`` of the ``segment`` command (#205, #209) * Added the ``--version`` option of the CLI, and reading the captions from standard input with ``-`` as the ``PATH`` of the ``shift`` and ``sync`` commands (#169, #207) * Added running the CLI with ``python -m webvtt`` (#210) * Fixed reading SRT files with whitespace around the first cue index, thanks to `@youdie006 `_ (#130) * Fixed WebVTT styles ignored when ``STYLE`` is followed by whitespace (#133) * Fixed WebVTT regions ignored after header text with ``-->`` (#135) * Fixed reading SRT and SBV files that start with blank lines or with a cue without text, which raised ``MalformedFileError`` (#142, #158) * Fixed non-ASCII digits read as numbers in timestamps and SRT cue indexes, thanks to `@emirmrkaljevic `_ (#140, #147) * Fixed ``from_buffer`` keeping a name that is not a path as the ``file`` to save to, such as ```` or the file descriptor of a file opened from one, which ``save`` wrote to ``.vtt`` or failed on with ``TypeError`` (#206, #208) * Fixed comparing a ``Timestamp`` with other types raising ``AttributeError``: ``==`` returns ``False`` and ordering comparisons raise ``TypeError`` (#137, #150) * Fixed the SRT output not ending with a line break (#138, #151) * Fixed SRT output of captions without text, such as text of blank lines, which were written as cues without text that are lost when read back: they are left out and the others numbered consecutively (#166, #202) * Fixed ``save`` leaving out the Byte Order Mark when the encoding is written differently, such as ``UTF-8`` or ``utf8`` (#160, #196) * Fixed HLS segments repeating a cue that ends on a segment boundary, and the playlist having no segments for captions without cues, thanks to `@radixun `_ (#145, #149) * Fixed segmenting raising ``IndexError`` for a cue that ends before it starts, which is now written to the segment it starts in (#152, #154) * Fixed the ``read_buffer`` deprecation warning pointing at the library instead of the code calling it (#139, #153) 0.6.0 (25-10-2026) ------------------ This is the last release supporting Python 3.7, 3.8 and 3.9. Changes that may need updates to the code using the library: * ``WebVTT`` takes keyword arguments only and does not read files, so ``WebVTT('captions.vtt')`` raises ``TypeError``: use ``webvtt.read`` to read captions from a file (#123) * Writing WebVTT raises ``ValueError`` for text that could not be read back, such as an empty line or ``-->`` in the text of a caption (#122) * Malformed cues are skipped when reading, instead of raising ``MalformedCaptionError``, unless ``strict=True`` (#93, #99) * Timestamps are limited to ``9999999999:59:59.999``: cues past it are skipped when reading, and ``shift``, ``sync`` and ``change_framerate`` raise ``ValueError`` instead of moving captions past it (#119) * The segment duration must be a whole number above zero, and the MPEG-TS value from 0 to 8589934591 (#125) Changes: * Added WebVTT regions (#108) * Added cue settings to captions (#107) * Added shift to captions, WebVTT and the CLI (#101) * Added sync and change_framerate to WebVTT and the CLI (#102) * Added Timestamp.from_seconds and Timestamp.in_milliseconds (#100) * Added strict option to raise on malformed cue blocks (#93) * Added keep_formatting to keep bold, italic, underline and the colours of the cue classes when saving SRT, thanks to `@darodi `_ (#39, #118) * Added manpage and documentation for the webvtt command (#95) * Added py.typed marker for type checkers (#92) * Added support for Python 3.13, 3.14 and 3.15, and tests on Windows and macOS (#84, #126) * Improved parsing to follow the WebVTT spec for blocks (#98) * Improved parsing to accept non-padded timestamps such as ``0:0:7.5``, as in Microsoft Teams transcripts, thanks to `@apetresc `_ (#45, #99) * Improved writing to keep the header text and metadata (#103) * Improved splitting text into lines to only break on CR, LF and CRLF (#104, #105) * Improved reading of SRT cue timings to work as most SRT readers do (#114), and of SBV cue timings to carry over fields over their range (#115) * Allowed more than two digits for timestamp hours, thanks to `@hlecnt `_ (#80) and `@uzumakienes `_ for SBV (#111) * Improved performance of Caption.text on long lines, thanks to `@BrianWillows `_ (#81), and of Caption.voice, thanks to `@uzumakienes `_ (#112) * Improved segments to keep the styles, regions and identifiers of the captions (#125) * Deprecated the file argument of WebVTT in favor of the file attribute (#123) * Fixed reading buffers with a leading BOM, thanks to `@natesute `_ (#82) * Fixed the HLS playlist to use the configured segment duration, thanks to `@shkyyy18 `_ (#83) * Fixed IndexError in slice_segments when the last caption ends on a segment boundary, thanks to `@t-hiroishi `_ (#70), and when the captions are not sorted by end time, thanks to `@uzumakienes `_ (#110) * Fixed SRT caption text lost after empty lines (#109) and after lines with only digits (#120) * Fixed saving leaving an empty file when the captions cannot be encoded (#121) * Fixed the API reference, the documentation of the exceptions and the examples (#90, #91, #127), and a docstring typo, thanks to `@Frenzie `_ (#72) 0.5.1 (30-05-2024) ------------------ * Added voice span support (#55) * Extended from_buffer support to allow BytesIO and also other format conversions (#32) * Fixed save SRT to not include cue tags, thanks to `@lilaboc `_ (#56) * Fixed saved caption to include a line break after the last caption as per standard (#49) 0.5.0 (15-05-2024) ------------------ * Added styles support * Added comments support * Added from_string * Added iterate over a slice of captions * Refactor of the library * Parser is no longer strict and ignores malformed blocks * Improved BOM support allowing to keep the BOM or remove it * Deprecated read_buffer in favor of from_buffer * Removed support for old versions of Python: 3.4, 3.5 and 3.6 0.4.6 (18-11-2020) ------------------ * Add capability to get WebVTT formatted content without an output file, thanks to `@DawoudSheraz `_ (#34) * Add Python 3.9 support 0.4.5 (09-04-2020) ------------------ * Fix issue reading buffer 0.4.4 (27-03-2020) ------------------ * Allow parsing empty SBV captions, thanks to `@ishunyu `_ (#26) * Fix invalid time cues, thanks to `@sontek `_ (#19) * Enable pytest as test runner, thanks to `@sontek `_ (#20) * Packaging improvements * Added Python 3.8 support * Improve parsing empty lines 0.4.3 (22-11-2019) ------------------ * Parsing improvements, thanks to `@sontek `_ (#18) * Add support for reading content from a file-like object, thanks to `@omerholz `_ (#23) * Documentation fixes thanks to `@sontek `_ (#22) and `@netcmcc `_ (#24) 0.4.2 (08-06-2018) ------------------ * Renamed and reorganized few of the modules * Parsing methods are now class methods: read, from_srt and from_sbv * Improved usability with the addition of shortcuts to avoid instantiating the classes so we can do: import webvtt webvtt.read('captions.vtt') # this will return a WebVTT instance 0.4.1 (24-12-2017) ------------------ * Support for saving cue identifiers 0.4.0 (18-09-2017) ------------------ The main goal of this release is a refactor of the WebVTT parser to be able to parse easier and give support to new features of the format. New features: * Support for cue identifiers * Support for parsing WebVTT captions with comments * Support for parsing WebVTT captions with Style blocks * Support for BOM in caption files * Added method to write the captions to an opened file * Convert WebVTT to SRT format * Ignore empty captions in SRT format Other: * Refactored WebVTT parser 0.3.3 (23-08-2017) ------------------ The text for the caption is now returned clean (tags removed). The cue text could contain tags like: * timestamp tags: *<00:19.000>* * class tags: *text* * and others... **Important**: It currently removes any tag present in the cue text. For example would be removed. Also a new attribute is available on captions to retrieve the text without cleaning tags: **raw_text** 0.3.2 (11-08-2017) ------------------ The goal of this release if to allow the WebVTT parser to be able to read caption files that contain metadata headers that extend to more than one line. 0.3.1 (08-08-2017) ------------------ * Made hours in WebVTT parser optional as per specs. * Added support to parse WebVTT files that contain metadata headers. 0.3.0 (02-06-2016) ------------------ New features: * Added support for YouTube SBV captions. * Added easy iteration to WebVTT class. * New CLI command for segmenting captions for HLS. Other: * Improved parsers to reuse functionality. * Added an exception for invalid timestamps in captions. * Added an exception when saving without a filename. 0.2.0 (23-05-2016) ------------------ * Refactor of the main module and parsers. 0.1.0 (20-05-2016) ------------------ This module is released with the following initial features: * Read/Edit/Write WebVTT captions. * Read SRT captions and convert to WebVTT. * Segment WebVTT files for captioning HLS video. glut23-webvtt-py-cca74e4/LICENSE000066400000000000000000000020601527171777000163060ustar00rootroot00000000000000MIT License Copyright (c) 2016 Alejandro Mendez Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.glut23-webvtt-py-cca74e4/MANIFEST.in000066400000000000000000000002211527171777000170340ustar00rootroot00000000000000include LICENSE include README.rst include man/webvtt.1 include webvtt/py.typed graft tests global-exclude __pycache__ global-exclude *.py[co] glut23-webvtt-py-cca74e4/Makefile000066400000000000000000000005201527171777000167400ustar00rootroot00000000000000build: man rm -rf dist mkdir dist python setup.py sdist bdist_wheel twine check dist/* release_test: build twine upload -r testpypi dist/* release: build twine upload dist/* man: sphinx-build -q -E -b man docs/source docs/build/man mkdir -p man cp docs/build/man/webvtt.1 man/webvtt.1 .PHONY: build release_test release manglut23-webvtt-py-cca74e4/README.rst000066400000000000000000000115471527171777000170020ustar00rootroot00000000000000webvtt-py ========= |pypi| |pyversions| |license| |coverage| |build-status| |docs-status| |downloads| ``webvtt-py`` is a Python library for reading, writing and converting WebVTT_ caption files. It also features caption segmentation useful when captioning `HLS videos`_. Documentation is available at https://webvtt-py.readthedocs.io. .. _`WebVTT`: https://www.w3.org/TR/webvtt1/ .. _`HLS videos`: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis Installation ------------ :: $ pip install webvtt-py Usage ----- .. code-block:: python import webvtt for caption in webvtt.read('captions.vtt'): print(caption.identifier) # cue identifier if any print(caption.start) # cue start time print(caption.end) # cue end time print(caption.text) # cue payload print(caption.voice) # cue voice span if any Writing captions ---------------- .. code-block:: python from webvtt import WebVTT, Caption vtt = WebVTT() vtt.captions.append( Caption('00:00:00.500', '00:00:07.000', 'Caption line 1\nCaption line 2') ) vtt.captions.append( Caption('00:00:07.000', '00:00:11.890', ['Caption line 1', 'Caption line 2']) ) vtt.save('captions.vtt') # or get the content without writing a file print(vtt.content) Segmenting for HLS ------------------ .. code-block:: python import webvtt webvtt.segment('captions.vtt', 'output/path') Converting captions from other formats -------------------------------------- Supported formats: * SubRip (.srt) * YouTube SBV (.sbv) .. code-block:: python import webvtt # write captions.vtt next to captions.srt vtt = webvtt.from_srt('captions.srt') vtt.save() # alternatively in a single line webvtt.from_sbv('captions.sbv').save() CLI --- Caption segmentation, shifting and synchronizing are also available from the command line with the ``webvtt`` executable. Given ``captions.vtt``: .. code-block:: text WEBVTT 00:00:01.000 --> 00:00:04.000 Hello and welcome. 00:00:06.000 --> 00:00:12.000 This caption spans two segments. 00:00:14.500 --> 00:00:18.000 Thanks for watching! Segment it into 10-second segments: :: $ webvtt segment captions.vtt --output out $ ls out fileSequence0.webvtt fileSequence1.webvtt prog_index.m3u8 The playlist ``out/prog_index.m3u8`` lists the segments: .. code-block:: text #EXTM3U #EXT-X-TARGETDURATION:10 #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXTINF:10.00000, fileSequence0.webvtt #EXTINF:10.00000, fileSequence1.webvtt #EXT-X-ENDLIST A caption crossing a segment boundary is included in every segment it overlaps, ``out/fileSequence1.webvtt``: .. code-block:: text WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:06.000 --> 00:00:12.000 This caption spans two segments. 00:00:14.500 --> 00:00:18.000 Thanks for watching! Use ``-d`` to change the segment duration and ``-m`` to change the MPEG-TS value. Show the captions 1.5 seconds earlier, writing them to a new file: :: $ webvtt shift captions.vtt -1.5 --output shifted.vtt Without ``--output`` the shifted captions are written to standard output. Use ``--start`` to only shift the captions starting at or after a timestamp. Fix captions that drift out of sync by moving two or more points, here the caption at 00:14.500 to 00:15.850 keeping the first one in place, or by changing the frame rate they are timed for: :: $ webvtt sync captions.vtt -p 00:01.000 00:01.000 -p 00:14.500 00:15.850 $ webvtt sync captions.vtt --fps 25 23.976 -o captions.vtt For all the options, run ``webvtt segment --help``, ``webvtt shift --help``, ``webvtt sync --help`` or ``man webvtt``, or see the `CLI documentation`_. .. _`CLI documentation`: https://webvtt-py.readthedocs.io/en/latest/cli.html License ------- Licensed under the MIT License. .. |pypi| image:: https://img.shields.io/pypi/v/webvtt-py.svg :target: https://pypi.org/project/webvtt-py/ .. |pyversions| image:: https://img.shields.io/pypi/pyversions/webvtt-py.svg :alt: Supported Python versions :target: https://pypi.org/project/webvtt-py/ .. |license| image:: https://img.shields.io/pypi/l/webvtt-py.svg :alt: MIT License :target: https://opensource.org/licenses/MIT .. |coverage| image:: https://codecov.io/gh/glut23/webvtt-py/graph/badge.svg?branch=master :target: https://codecov.io/gh/glut23/webvtt-py .. |build-status| image:: https://github.com/glut23/webvtt-py/actions/workflows/ci.yml/badge.svg?branch=master :target: https://github.com/glut23/webvtt-py/actions/workflows/ci.yml .. |docs-status| image:: https://readthedocs.org/projects/webvtt-py/badge/?version=latest :target: https://webvtt-py.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status .. |downloads| image:: https://static.pepy.tech/badge/webvtt-py :target: https://pepy.tech/project/webvtt-py :alt: Downloadsglut23-webvtt-py-cca74e4/docs/000077500000000000000000000000001527171777000162335ustar00rootroot00000000000000glut23-webvtt-py-cca74e4/docs/Makefile000066400000000000000000000011751527171777000176770ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)glut23-webvtt-py-cca74e4/docs/make.bat000066400000000000000000000014351527171777000176430ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=source set BUILDDIR=build if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popdglut23-webvtt-py-cca74e4/docs/requirements.txt000066400000000000000000000000451527171777000215160ustar00rootroot00000000000000sphinx==7.3.7 sphinx-rtd-theme==2.0.0glut23-webvtt-py-cca74e4/docs/source/000077500000000000000000000000001527171777000175335ustar00rootroot00000000000000glut23-webvtt-py-cca74e4/docs/source/cli.rst000066400000000000000000000236171527171777000210450ustar00rootroot00000000000000.. This page is also the webvtt(1) manpage. After editing it, run ``make man`` to regenerate man/webvtt.1. Command line ============ Synopsis -------- :: webvtt segment PATH -o OUTPUT [-d NUMBER] [-m NUMBER] [--duration TIME] [--overwrite] webvtt shift PATH OFFSET [-o OUTPUT] [-s TIMESTAMP] webvtt sync PATH (-p FROM TO -p FROM TO ... | --fps FROM TO) [-o OUTPUT] webvtt -h webvtt --version Description ----------- ``webvtt`` segments a WebVTT caption file for `HTTP Live Streaming`_ (HLS) and shifts or synchronizes the timestamps of its captions. ``segment`` splits the captions in ``PATH`` into segments of a target duration. Each segment is written to ``OUTPUT`` as ``fileSequenceN.webvtt``, where ``N`` is the segment index starting at 0, along with a playlist named ``prog_index.m3u8`` that lists all the segments. An ``OUTPUT`` with the playlist or segments of an earlier run is an error, unless ``--overwrite`` is given. A caption whose time span crosses a segment boundary is included in every segment it overlaps, so players display it regardless of which segment they load. Every segment also has the ``STYLE`` and ``REGION`` blocks of ``PATH``, which its captions use, and the captions keep their identifiers and cue settings. Comments are not written to the segments. Every segment starts with an ``X-TIMESTAMP-MAP`` header that maps the local caption time ``00:00:00.000`` to an MPEG-TS presentation timestamp, so the captions are kept in sync with the video stream. .. _`HTTP Live Streaming`: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis ``shift`` moves the captions in ``PATH`` earlier or later by ``OFFSET`` seconds. The offset is rounded to the nearest millisecond as written, with halves rounded away from zero, so ``1.0005`` is 1001 milliseconds. A timestamp that would fall before zero is set to ``00:00:00.000``, so a caption moved entirely before the start is kept with ``00:00:00.000 --> 00:00:00.000`` timings. The shifted captions are written to standard output in UTF-8, or to ``OUTPUT`` when given. ``sync`` fixes captions that drift further out of sync along the video. With ``-p``, each point moves the time ``FROM`` in the captions to ``TO``; times between two points are moved along the line between them, and times before the first or after the last point along the line of the outermost points. With ``--fps``, the timing is changed from one frame rate to another. Times are rounded and set to zero like with ``shift``, and the output is written the same way. The commands can also be run as ``python -m webvtt``, as in ``python -m webvtt shift captions.vtt 2``. ``segment``, ``shift`` and ``sync`` read the captions from standard input when ``PATH`` is ``-``, decoded as UTF-8 as WebVTT requires, so they can be used in pipes. A file named ``-`` can be read as ``./-``. Commands -------- ``segment`` Segment the WebVTT file at ``PATH``. ``shift`` Shift the timestamps of the WebVTT file at ``PATH``. ``sync`` Synchronize the timestamps of the WebVTT file at ``PATH``. Options ------- ``-h``, ``--help`` Show the help message and exit. Also available for each command, as in ``webvtt shift -h``. ``--version`` Show the version of webvtt-py and exit. Segment options ~~~~~~~~~~~~~~~ ``-o OUTPUT``, ``--output OUTPUT`` Directory where the segments and playlist are written. It is created if it does not exist. Required. ``-d NUMBER``, ``--target-duration NUMBER`` Target duration of each segment in seconds, a whole number above zero. Also used for the ``#EXT-X-TARGETDURATION`` value of the playlist and the ``#EXTINF`` of its segments, except the last with ``--duration``. Default: ``10``. ``-m NUMBER``, ``--mpegts NUMBER`` MPEG-TS value written to the ``X-TIMESTAMP-MAP`` header of each segment, a whole number from 0 to 8589934591, as MPEG-TS timestamps have 33 bits. Default: ``900000`` (10 seconds at the 90 kHz MPEG-TS clock). ``--duration TIME`` Length of the media, as a timestamp such as ``01:25.500`` or a number of seconds such as ``85.5``. The playlist then covers all the media, with empty segments after the last caption, and the ``#EXTINF`` of its last segment ends with the media. A caption starting at or after it is an error. Without it, the playlist ends with the segment of the last caption, and every segment lasts the target duration. ``--overwrite`` Replace the playlist and segments of an earlier run in ``OUTPUT``, and delete the segments of that run that the new playlist does not have. Other files in ``OUTPUT`` are left alone. Without it, an ``OUTPUT`` with a ``prog_index.m3u8`` or ``fileSequenceN.webvtt`` file is an error. Shift options ~~~~~~~~~~~~~ ``OFFSET`` Number of seconds to shift the captions by, such as ``2`` or ``0.25``. Negative values move the captions earlier, as in ``-1.5``. Required. ``-o OUTPUT``, ``--output OUTPUT`` File where the shifted captions are written, keeping the encoding and Byte Order Mark of the input file. It can be the input file itself to shift it in place. A ``.vtt`` extension is added if it has no extension, and if it is a directory the file is written there with the input file name, which is an error when reading standard input. Default: standard output. ``-s TIMESTAMP``, ``--start TIMESTAMP`` Only shift the captions starting at or after ``TIMESTAMP``, in ``[hh:]mm:ss.mmm`` format. Default: shift all the captions. Sync options ~~~~~~~~~~~~ ``-p FROM TO``, ``--point FROM TO`` Move the time ``FROM`` in the captions to ``TO``. Each time is a timestamp in ``[hh:]mm:ss.mmm`` format or a number of seconds. Give it two or more times, in any order. Two points cannot have the same ``FROM``, and the ``TO`` times must be in the same order as the ``FROM`` times. ``--fps FROM TO`` Change the timing from the frame rate ``FROM`` the captions are timed for to the frame rate ``TO`` of the video, such as ``25 23.976``. A frame rate can also be a ratio, such as ``24000/1001`` for the exact NTSC rate. Times are multiplied by ``FROM / TO``. Cannot be used with ``-p``. ``-o OUTPUT``, ``--output OUTPUT`` File where the synchronized captions are written, as with ``shift``. Default: standard output. Exit status ----------- ``0`` The command completed successfully. ``1`` The input could not be read or is not a valid WebVTT file, the captions could not be moved as asked, or the output could not be written. For ``segment``, it is also an error when the output directory has the files of an earlier run without ``--overwrite``, or when a caption starts at or after ``--duration``. An error message is printed to standard error, for example:: webvtt: error: captions.vtt: not a valid WebVTT file ``2`` Invalid command line arguments, such as an offset that is not a number, an invalid ``--start`` timestamp or sync points that cannot be used. Examples -------- Given ``captions.vtt``: .. code-block:: text WEBVTT 00:00:01.000 --> 00:00:04.000 Hello and welcome. 00:00:06.000 --> 00:00:12.000 This caption spans two segments. 00:00:14.500 --> 00:00:18.000 Thanks for watching! Segment it into 10-second segments: :: $ webvtt segment captions.vtt --output out $ ls out fileSequence0.webvtt fileSequence1.webvtt prog_index.m3u8 ``out/prog_index.m3u8``: .. code-block:: text #EXTM3U #EXT-X-TARGETDURATION:10 #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXTINF:10.00000, fileSequence0.webvtt #EXTINF:10.00000, fileSequence1.webvtt #EXT-X-ENDLIST ``out/fileSequence0.webvtt``: .. code-block:: text WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:01.000 --> 00:00:04.000 Hello and welcome. 00:00:06.000 --> 00:00:12.000 This caption spans two segments. ``out/fileSequence1.webvtt``: .. code-block:: text WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:06.000 --> 00:00:12.000 This caption spans two segments. 00:00:14.500 --> 00:00:18.000 Thanks for watching! Segment it again into ``out``, with 6-second segments and a different MPEG-TS value, replacing the earlier segments: :: $ webvtt segment captions.vtt -o out webvtt: error: out: has the files of an earlier segmentation, such as fileSequence0.webvtt; use --overwrite to replace them $ webvtt segment captions.vtt -o out -d 6 -m 0 --overwrite Show the captions 1.5 seconds earlier: :: $ webvtt shift captions.vtt -1.5 WEBVTT 00:00:00.000 --> 00:00:02.500 Hello and welcome. 00:00:04.500 --> 00:00:10.500 This caption spans two segments. 00:00:13.000 --> 00:00:16.500 Thanks for watching! Show the last caption 2 seconds later, updating the file in place: :: $ webvtt shift captions.vtt 2 --start 00:14.500 -o captions.vtt Keep the first caption in place and move the last one from 00:14.500 to 00:15.850, synchronizing the captions in between: :: $ webvtt sync captions.vtt -p 00:01.000 00:01.000 -p 00:14.500 00:15.850 WEBVTT 00:00:01.000 --> 00:00:04.300 Hello and welcome. 00:00:06.500 --> 00:00:13.100 This caption spans two segments. 00:00:15.850 --> 00:00:19.700 Thanks for watching! Fix captions timed for a 25 fps video played with a 23.976 fps one, updating the file in place: :: $ webvtt sync captions.vtt --fps 25 23.976 -o captions.vtt Shift and synchronize the captions in a pipe, reading them from standard input: :: $ webvtt shift captions.vtt 2 | webvtt sync - --fps 25 23.976 > fixed.vtt Synchronize the captions and segment them, without a file in between: :: $ webvtt sync captions.vtt --fps 25 23.976 | webvtt segment - -o out See also -------- The same segmentation, shifting and synchronizing are available from Python with ``webvtt.segment()``, ``WebVTT.shift()``, ``WebVTT.sync()`` and ``WebVTT.change_framerate()``. glut23-webvtt-py-cca74e4/docs/source/conf.py000066400000000000000000000021421527171777000210310ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. import sys from pathlib import Path from datetime import date # Add parent dir to path parent_directory = Path(__file__).parent.parent.parent.resolve() sys.path.append(str(parent_directory)) import webvtt # noqa # -- Project information project = 'webvtt-py' copyright = f'2016-{date.today().year}, {webvtt.__author__}' author = webvtt.__author__ release = webvtt.__version__ version = webvtt.__version__ # -- General configuration extensions = [ 'sphinx.ext.duration', 'sphinx.ext.doctest', 'sphinx.ext.autodoc', 'sphinx.ext.autosummary', 'sphinx.ext.intersphinx', ] intersphinx_mapping = { 'python': ('https://docs.python.org/3/', None), 'sphinx': ('https://www.sphinx-doc.org/en/master/', None), } intersphinx_disabled_domains = ['std'] templates_path = ['_templates'] # -- Options for HTML output html_theme = 'sphinx_rtd_theme' # -- Options for EPUB output epub_show_urls = 'footnote' # -- Options for manual page output man_pages = [ ('cli', 'webvtt', 'segment, shift and sync WebVTT captions', [author], 1), ] glut23-webvtt-py-cca74e4/docs/source/history.rst000066400000000000000000000000401527171777000217600ustar00rootroot00000000000000.. include:: ../../CHANGELOG.rstglut23-webvtt-py-cca74e4/docs/source/index.rst000066400000000000000000000003121527171777000213700ustar00rootroot00000000000000webvtt-py ========= Contents: .. toctree:: :maxdepth: 2 quickstart usage cli package upgrading history Indices and tables ================== * :ref:`genindex` * :ref:`modindex`glut23-webvtt-py-cca74e4/docs/source/package.rst000066400000000000000000000015401527171777000216600ustar00rootroot00000000000000webvtt-py package reference =========================== webvtt.webvtt ------------- .. automodule:: webvtt.webvtt :members: webvtt.models ------------- .. automodule:: webvtt.models :members: :inherited-members: webvtt.segmenter ---------------- .. automodule:: webvtt.segmenter :members: webvtt.cli ---------- .. automodule:: webvtt.cli :members: Formats ------- Each supported format has its own module that takes care of parsing and, when supported, writing captions. webvtt.vtt ~~~~~~~~~~ .. automodule:: webvtt.vtt :members: webvtt.srt ~~~~~~~~~~ .. automodule:: webvtt.srt :members: webvtt.sbv ~~~~~~~~~~ .. automodule:: webvtt.sbv :members: webvtt.utils ------------ .. automodule:: webvtt.utils :members: webvtt.errors ------------- .. automodule:: webvtt.errors :members: :show-inheritance: glut23-webvtt-py-cca74e4/docs/source/quickstart.rst000066400000000000000000000005371527171777000224640ustar00rootroot00000000000000Quickstart ========== Installation ------------ You can install ``webvtt-py`` with pip: :: $ pip install webvtt-py Requirements ------------ This module requires Python 3.10+. Source code ----------- This project is hosted on `GitHub`_. .. _`GitHub`: https://github.com/glut23/webvtt-py License ------- Licensed under the MIT License. glut23-webvtt-py-cca74e4/docs/source/upgrading.rst000066400000000000000000000116521527171777000222520ustar00rootroot00000000000000Upgrading ========= Upgrading to 0.7.0 ------------------ 0.7.0 has changes that may need updates to the code using the library. These are the ones most likely to need them, with what to change. The :doc:`history` lists all of them. Python 3.10 or later ~~~~~~~~~~~~~~~~~~~~ Python 3.7, 3.8 and 3.9 are no longer supported, as they are end-of-life. Stay on webvtt-py 0.6.0 to run on them: :: $ pip install 'webvtt-py<0.7' Segmenting into a folder with an earlier run ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Segmenting into a folder that has the playlist or segments of an earlier run raises ``FileExistsError``, and the ``segment`` command exits with an error, instead of writing over them. Pass ``overwrite=True``, or ``--overwrite`` on the command line, to replace them, which also deletes the segments of the earlier run that the new playlist does not have: .. code-block:: python import webvtt webvtt.segment('captions.vtt', 'output/path', overwrite=True) :: $ webvtt segment captions.vtt -o output/path --overwrite Reading strings and bytes ~~~~~~~~~~~~~~~~~~~~~~~~~ ``from_buffer`` raises ``TypeError`` for a string or bytes, which are not file-like objects and it never read, instead of ``MalformedFileError``. Code that catches ``MalformedFileError`` for them gets the new error. Read a string with ``from_string``, and bytes as a file-like object: .. code-block:: python import io import webvtt vtt = webvtt.from_string('WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nHi\n') vtt = webvtt.from_buffer(io.BytesIO(b'WEBVTT\n')) ``from_buffer`` also raises ``ValueError`` for ``strict=True`` with the ``srt`` or ``sbv`` format, which have no strict parsing, instead of ignoring it. Remove ``strict=True`` for those formats. Saving to paths with an extension ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``save``, ``save_as_srt`` and the ``-o`` option of the CLI write a path with an extension as given, so ``captions.webvtt`` is no longer saved as ``captions.webvtt.vtt``. Saving without a path, or to a folder, keeps the extension of the file that was read when it is of the format, so captions read from ``captions.webvtt`` are saved to it instead of ``captions.vtt``. Pass the full path to save to another name: .. code-block:: python import webvtt vtt = webvtt.read('captions.webvtt') vtt.save() # captions.webvtt vtt.save('captions.vtt') # captions.vtt SRT encoding ~~~~~~~~~~~~ ``save_as_srt`` saves captions read from SRT with an ``encoding`` in that encoding, instead of UTF-8. Other captions are saved in UTF-8, instead of the encoding of the locale. Pass the encoding to save in another one: .. code-block:: python import webvtt vtt = webvtt.from_srt('captions.srt', encoding='cp1252') vtt.save_as_srt('cp1252.srt') # cp1252 vtt.save_as_srt('utf-8.srt', encoding='utf-8') # UTF-8 Byte Order Mark ~~~~~~~~~~~~~~~ ``save`` raises ``ValueError`` for ``add_bom=True`` with an encoding without a Byte Order Mark, such as ``latin-1``, and for ``add_bom=False`` with ``utf-8-sig``, ``utf-16`` or ``utf-32``, which always write one, instead of ignoring ``add_bom``. Use an encoding that matches: ``utf-8`` without a Byte Order Mark, or ``utf-8`` with ``add_bom=True`` for one. For UTF-16 or UTF-32 without a Byte Order Mark, use an encoding with the byte order, such as ``utf-16-le``. Strict parsing ~~~~~~~~~~~~~~ Strict parsing raises errors for more content that is not valid WebVTT: cues whose end time is not after the start time, blocks that are not a cue, comment, style or region, comments where ``NOTE`` is not followed by a space, a tab or a line break, styles after the first cue, and ``WEBVTT`` followed by other text in the first line, such as ``WEBVTTX``. Files that passed strict parsing before may raise an error now; read them without ``strict=True`` to skip those blocks, as the default parsing does. Default parsing also follows WebVTT more closely: styles after the first cue and blocks such as ``NOTEBOOK`` are ignored, and SRT cues without an index are read instead of skipped. Text that cannot be written ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Writing WebVTT raises ``ValueError`` for ``-->`` in a comment or in the header text, which WebVTT does not allow, instead of writing a file that cannot be read back. Caption text ~~~~~~~~~~~~ Setting ``Caption.text`` to a value that is not a string or a sequence of lines raises ``TypeError`` instead of ``AttributeError``, and creating a ``Caption`` with lines that are not strings raises it instead of accepting them. Code that catches ``AttributeError`` for it gets the new error. Lists of items and lines ~~~~~~~~~~~~~~~~~~~~~~~~ ``WebVTT`` keeps the lists given as arguments as its attributes, also when they are empty, so adding to an empty list after creating the instance shows in it. ``Style`` copies a list of lines, as ``Caption`` does. Pass a copy, such as ``list(captions)``, to keep a list apart from the instance. glut23-webvtt-py-cca74e4/docs/source/usage.rst000066400000000000000000000704211527171777000213750ustar00rootroot00000000000000Usage ===== Reading WebVTT caption files ---------------------------- .. code-block:: python import webvtt vtt = webvtt.read('captions.vtt') # iterate over the captions for caption in vtt: print(caption.start) # start timestamp in text format print(caption.end) # end timestamp in text format print(caption.text) # caption text print(caption.voice) # voice span if present # iterate over the lines of a particular caption for line in vtt[0].lines: print(line) # the caption text is returned clean without cue tags, # the raw text of a caption is available in raw_text vtt[0].text # 'This is a caption text' vtt[0].raw_text # 'This is a caption text' # caption identifiers vtt[0].identifier # 'chapter 1' Reading WebVTT caption files from a file-like object ---------------------------------------------------- .. code-block:: python import webvtt import requests from io import StringIO payload = requests.get('https://example.com/captions.vtt').text buffer = StringIO(payload) for caption in webvtt.from_buffer(buffer): print(caption.start) print(caption.end) print(caption.text) Reading caption files from a binary file-like object ---------------------------------------------------- Binary data, such as a file opened in ``'rb'`` mode or a ``BytesIO`` object, is decoded with the encoding of its Byte Order Mark, if any, or else with ``encoding``, UTF-8 by default. With a Byte Order Mark, saving keeps its encoding and the Byte Order Mark, as when reading a file. .. code-block:: python import webvtt from io import BytesIO with open('captions.vtt', 'rb') as f: vtt = webvtt.from_buffer(f) # formats supported: vtt, srt, sbv with open('captions.srt', 'rb') as f: vtt = webvtt.from_buffer(f, format='srt') # binary data in another encoding without a Byte Order Mark buffer = BytesIO('WEBVTT\n'.encode('latin-1')) vtt = webvtt.from_buffer(buffer, encoding='latin-1') A string is not a file-like object and raises ``TypeError``: use ``from_string`` to read captions from a string. The path of a file opened by name is kept in the ``file`` attribute, so ``save()`` without a path saves to it. A file-like object without a path, such as standard input, a file opened from a file descriptor or a ``BytesIO`` object, gives captions without a file, as when creating ``WebVTT`` objects, so ``save()`` needs a path. Reading WebVTT captions from a string ------------------------------------- .. code-block:: python import webvtt import textwrap vtt = webvtt.from_string(textwrap.dedent(""" WEBVTT 00:00:00.500 --> 00:00:07.000 Caption #1 00:00:07.000 --> 00:00:11.890 Caption #2 line 1 Caption #2 line 2 00:00:11.890 --> 00:00:16.320 Caption #3 """).strip() ) for caption in vtt: print(caption.start) print(caption.end) print(caption.text) Iterating over a slice of captions ---------------------------------- .. code-block:: python import webvtt vtt = webvtt.read('captions.vtt') for caption in vtt.iter_slice(start='00:00:11.000', end='00:00:27.000' ): print(caption.start) print(caption.end) print(caption.text) Only the captions within the range are returned: a caption that starts before ``start`` or ends after ``end`` is left out, even if part of it is in the range. Creating captions ----------------- .. code-block:: python from webvtt import WebVTT, Caption vtt = WebVTT() # creating a caption with a list of lines caption = Caption( '00:00:00.500', '00:00:07.000', ['Caption line 1', 'Caption line 2'] ) # an identifier can be assigned caption.identifier = 'chapter 1' # adding a caption vtt.captions.append(caption) # creating another caption with a text caption = Caption( '00:00:07.000', '00:00:11.890', 'Caption line 1\nCaption line 2' ) vtt.captions.append(caption) # save the captions to a file vtt.save('captions.vtt') The lists given to ``WebVTT``, such as ``WebVTT(captions=captions)``, are kept as its attributes, not copied, so changes to them show in the instance. The list of lines given to a ``Caption`` or a ``Style`` is copied. As in WebVTT, only CR, LF and CRLF break text into lines. Other characters such as U+2028 or a form feed stay in the line. Text that could not be read back from the file raises ``ValueError`` when saving or writing the captions or getting their ``content``, and an existing file is left unchanged: * an empty line followed by more text, or ``-->``, in a caption or a style * a line break or ``-->`` in an identifier * an empty line followed by more text, or ``-->``, in a comment * a line break or ``-->`` in the header text A ``WebVTT`` object created from scratch has no filename, so ``save()`` requires an output path. Calling it without one raises ``MissingFilenameError``. The ``file`` attribute holds the path the captions were read from or last saved to, and can be set to choose where ``save()`` writes: .. code-block:: python import webvtt vtt = webvtt.from_srt('captions.srt') # save() would write captions.vtt, next to the file that was read vtt.file = 'new_captions.vtt' vtt.save() ``WebVTT`` takes keyword arguments only and does not read files, so ``WebVTT('captions.vtt')`` raises ``TypeError``: use ``webvtt.read()`` to read captions from a file. The ``file`` argument of ``WebVTT`` is deprecated in favor of the ``file`` attribute. Manipulating captions --------------------- .. code-block:: python import webvtt vtt = webvtt.read('captions.vtt') # update start timestamp vtt[0].start = '00:00:01.250' # update end timestamp vtt[0].end = '00:00:03.890' # update caption text vtt[0].text = 'New caption text' # or set its lines vtt[0].text = ['New caption text', 'on two lines'] # delete a caption del vtt.captions[2] The text of a caption is a string, split on its line breaks, or a sequence of lines, as when creating a ``Caption``. Any other value raises ``TypeError``. Cue settings ------------ The cue settings that follow the timestamps of a cue, such as ``line:0 align:start``, position and align the caption. They are available as a ``CueSettings`` object in the ``settings`` attribute of a caption, with a property for each setting that returns its value or ``None`` when it is not set: .. code-block:: python import webvtt from webvtt import Caption, CueSettings vtt = webvtt.read('captions.vtt') caption = vtt[0] caption.settings.line # '10%,end' caption.settings.align # 'start' str(caption.settings) # 'line:10%,end align:start' # change, add or remove a setting caption.settings.line = '0' caption.settings.position = '50%,center' caption.settings.size = None # replace or clear all the settings caption.settings = 'line:0 align:start' caption.settings = None # create a caption with settings caption = Caption( '00:00:00.500', '00:00:07.000', 'Caption text', settings=CueSettings(line='0', align='start') ) The supported settings are ``vertical``, ``line``, ``position``, ``size``, ``align`` and ``region``. Setting a value that is not valid as per the WebVTT specification, such as ``align = 'middle'``, raises ``MalformedCaptionError``. The settings are saved as they were read, in the same order and including the ones that are not valid or not known, separated by a single space. When a setting is not valid, such as ``align:middle`` from an older version of the specification, its property returns ``None`` and assigning a new value replaces it. When converting to SRT, the cue settings are not included as the format does not support them. Shifting captions ----------------- Captions can be moved earlier or later by an offset, given as a number of seconds or a ``timedelta``: .. code-block:: python import webvtt from datetime import timedelta vtt = webvtt.read('captions.vtt') # show all the captions 1.5 seconds earlier vtt.shift(-1.5) # show the captions from 00:12:30 onwards 3 seconds later vtt.shift(3, start='00:12:30.000') # shift a single caption vtt[4].shift(0.25) # the offset can also be a timedelta vtt.shift(timedelta(minutes=1, seconds=30)) vtt.save() The offset is rounded to the nearest millisecond of its decimal value, with halves rounded away from zero, so ``0.0005`` is 1 millisecond and ``-0.0005`` is -1 millisecond. A timestamp that would fall before zero is set to ``00:00:00.000``, so a caption moved entirely before the start is kept with ``00:00:00.000 --> 00:00:00.000`` timings. Shifting is also available from the command line, see :doc:`cli`. Synchronizing captions ---------------------- When the captions are in sync at the start but drift further out of sync over time, usually because they were timed for a video with a different frame rate or cut, shifting is not enough. ``sync`` moves the captions to two or more points, each a ``(from, to)`` pair where the time ``from`` in the captions is moved to ``to``: .. code-block:: python import webvtt vtt = webvtt.read('captions.vtt') # the caption at 00:00:05.000 should be at 00:00:04.200 and the one at # 01:30:00.000 at 01:31:12.500, everything else is moved along vtt.sync(('00:00:05.000', '00:00:04.200'), ('01:30:00.000', '01:31:12.500')) # points can be taken from the captions themselves vtt.sync((vtt[0].start, '00:00:04.200'), (vtt[-1].start, '01:31:12.500')) # with more points, each part between two points is synchronized on its # own, for captions whose drift is not constant vtt.sync(('00:00:05.000', '00:00:04.200'), ('00:45:00.000', '00:45:30.000'), ('01:30:00.000', '01:31:12.500')) # captions timed for a 25 fps video played with a 23.976 fps one vtt.change_framerate(25, 23.976) # exact frame rates, like the NTSC 24000/1001, can be given as fractions from fractions import Fraction vtt.change_framerate(25, Fraction(24000, 1001)) vtt.save() The times of the points can be timestamp strings, ``Timestamp`` objects, numbers of seconds or ``timedelta`` objects, and they can be given in any order. Times between two points are moved along the line between them, and times before the first or after the last point along the line of the outermost points. Start and end times are both moved, so the duration of the captions changes with the timing. As with ``shift``, times are rounded to the nearest millisecond with halves rounded away from zero, and a time that would fall before zero is set to ``00:00:00.000``. A ``ValueError`` is raised if fewer than two points are given, two points have the same ``from`` time, or the ``to`` times are not in the same order as the ``from`` times, since that would change the order of the captions. Use ``shift`` when all the captions are off by the same amount and ``sync`` when the difference grows along the video. Synchronizing is also available from the command line, see :doc:`cli`. Timestamps ---------- .. code-block:: python from webvtt.models import Timestamp # create a timestamp from a number of seconds, rounded to the # nearest millisecond with halves rounded away from zero str(Timestamp.from_seconds(5051.67)) # '01:24:11.670' # in_seconds leaves the milliseconds out Timestamp.from_string('01:24:11.670').in_seconds() # 5051 Timestamp.from_string('01:24:11.670').in_milliseconds() # 5051670 Saving captions --------------- .. code-block:: python import webvtt vtt = webvtt.read('captions.vtt') # save to the same file vtt.save() # save to a different file vtt.save('new_captions.vtt') # save to a file path, the .vtt extension is added when it has none vtt.save('other/folder/new_captions') # save to a folder, keeping the name of the file that was read vtt.save('other/folder') # write to an open file with open('other_captions.vtt', 'w') as f: vtt.write(f) Without a path, or with a folder, the name of the file that was read or last saved is used. A file with the ``.vtt`` or ``.webvtt`` extension keeps it, and any other extension is replaced by ``.vtt``, so captions read from ``captions.srt`` are saved to ``captions.vtt``. ``save_as_srt()`` does the same with ``.srt``. Retrieving WebVTT-formatted captions ------------------------------------ WebVTT content can be retrieved without an output file: .. code-block:: python import webvtt vtt = webvtt.read('captions.vtt') # print the content in WebVTT format print(vtt.content) Converting captions ------------------- Captions can be read from the following formats: * SubRip (.srt) * YouTube SBV (.sbv) .. code-block:: python import webvtt # read captions from SRT format vtt = webvtt.from_srt('captions.srt') # save the captions in WebVTT format vtt.save() # the conversion can be done by chaining the method calls webvtt.from_srt('captions.srt').save() # the same for SBV format vtt = webvtt.from_sbv('captions.sbv') Some SRT files have empty lines in the caption text. When reading SRT, a new caption starts at a cue index followed by the cue timings, even without an empty line before it. Any other text after an empty line stays in the current caption, and the empty lines are removed. The cue timings of SRT are read as in most SRT readers. The separator before the milliseconds can be a comma or a dot, the fields can be shorter, as in ``0:0:7,500``, and the milliseconds are read as a number, so ``00:00:07,5`` is 5 milliseconds. A field over its range carries over, so ``00:00:07,1500`` is read as ``00:00:08.500``. The hours and the milliseconds are required, and a cue without them is skipped. The cue timings of SBV carry over in the same way, so ``0:61:00.000`` is read as ``01:01:00.000``. Blank lines at the start of SRT and SBV files are skipped, and cues without text are skipped, so a file whose first cue has no text is read. SRT cues without an index are read, as most SRT players do, but the first cue of the file needs one for the content to be read as SRT. Convert WebVTT captions to other formats: * SubRip (.srt) .. code-block:: python import webvtt # save in SRT format vtt = webvtt.read('captions.vtt') vtt.save_as_srt() # write to an open file in SRT format with open('captions.srt', 'w') as f: vtt.write(f, format='srt') Captions without text, such as text of blank lines, are left out of the SRT file, as SRT cannot have a cue without text, and the others are numbered consecutively. The cue tags are removed when converting to SRT. To keep the formatting instead, use ``keep_formatting``. Bold, italic, underline and ``font`` tags are kept, and the cue classes with a ``color`` in the styles become ``font`` tags with that color. Other cue tags are removed, keeping their text: .. code-block:: python import webvtt vtt = webvtt.from_string("""\ WEBVTT STYLE ::cue(.magenta) { color: #ff00ff; } 00:00:00.500 --> 00:00:07.000 Hello world """) vtt.save_as_srt('captions.srt', keep_formatting=True) # the caption is saved as: # Hello world Only the ``color`` of ``::cue(.class)`` selectors is read. When a cue has more than one class with a color, the rule that comes last in the styles applies. Character references such as ``<`` are written as they are. The ``encoding`` given to read a file without a Byte Order Mark is only used for reading, as WebVTT files are UTF-8: ``save()`` writes UTF-8 unless another encoding is given. SRT files have no fixed encoding, so captions read with ``from_srt`` or ``from_buffer`` in SRT format and an ``encoding`` are saved by ``save_as_srt()`` in the same encoding: .. code-block:: python import webvtt vtt = webvtt.from_srt('captions.srt', encoding='cp1252') # saved in cp1252 vtt.save_as_srt('edited.srt') # saved in UTF-8, also for text that cp1252 cannot write vtt.save_as_srt('edited.srt', encoding='utf-8') # saved in UTF-8 vtt.save() Saving text that the encoding cannot write raises ``UnicodeEncodeError``, leaving an existing file unchanged. Segmenting captions for HLS --------------------------- .. code-block:: python import webvtt # write 10-second segments and a prog_index.m3u8 playlist to output/path webvtt.segment('captions.vtt', 'output/path') # use 6-second segments and a different MPEG-TS value webvtt.segment('captions.vtt', 'other/path', seconds=6, mpegts=0) # segment captions already read, edited or from another format, # without saving them first vtt = webvtt.from_srt('captions.srt') vtt.change_framerate(25, 23.976) webvtt.segment(vtt, 'srt/path') Every segment has the styles and regions of the captions, and the captions in it keep their identifiers and cue settings. Comments are not written to the segments. ``seconds`` is a whole number above zero and ``mpegts`` a whole number from 0 to 8589934591. Segmenting into a folder with the playlist or segments of an earlier run raises ``FileExistsError``, so they are not replaced by mistake. Use ``overwrite=True`` to replace them, which also deletes the segments of the earlier run that the new playlist does not have. Other files in the folder are left alone: .. code-block:: python # segment again into output/path, replacing the earlier segments webvtt.segment('captions.vtt', 'output/path', overwrite=True) The playlist ends with the segment of the last caption, and every segment lasts ``seconds``, as the length of the media is not known. Give it with ``duration``, as a timestamp, a number of seconds or a ``timedelta``, so the playlist covers all the media, with empty segments after the last caption, and its last segment ends with the media: .. code-block:: python # a video of 1 minute 25.5 seconds: 9 segments, the last of 5.5 seconds webvtt.segment('captions.vtt', 'output/path', duration='00:01:25.500') A caption starting at or after the end of the media raises ``ValueError``, as the duration is likely wrong, and a caption ending after it is kept in the last segment. Segmentation is also available from the command line, see :doc:`cli`. WebVTT files with Byte Order Mark (BOM) --------------------------------------- When the WebVTT file has a BOM, saving it keeps the BOM: .. code-block:: python import webvtt vtt = webvtt.read('captions_with_bom.vtt') # saved file keeps the BOM vtt.save() Add a BOM to a file without it: .. code-block:: python import webvtt vtt = webvtt.read('captions_without_bom.vtt') # saved file has a BOM vtt.save(add_bom=True) Remove the BOM from a file: .. code-block:: python import webvtt vtt = webvtt.read('captions_with_bom.vtt') # saved file does not have a BOM vtt.save(add_bom=False) Save a file with a different encoding: .. code-block:: python import webvtt vtt = webvtt.read('captions.vtt') vtt.save(encoding='utf-32-le') # save in a different encoding with a BOM vtt.save(encoding='utf-32-le', add_bom=True ) Encoding names are matched as Python does, so ``UTF-8`` and ``utf8`` are the same as ``utf-8``. The ``utf-8-sig``, ``utf-16`` and ``utf-32`` codecs always write a BOM, ``utf-16`` and ``utf-32`` in the byte order of the platform, so ``add_bom=False`` raises ``ValueError`` with them: use ``utf-16-le``, ``utf-16-be``, ``utf-32-le`` or ``utf-32-be`` to choose the byte order and whether to add a BOM. ``add_bom=True`` raises ``ValueError`` for an encoding without a BOM, such as ``latin-1``, while the BOM of the file that was read is left out when saving in such an encoding. When saving raises, an existing file is left unchanged. WebVTT styles ------------- .. code-block:: python import webvtt vtt = webvtt.read('captions.vtt') for style in vtt.styles: print(style.text) # retrieve list of lines print(style.lines) ``STYLE`` blocks after the first cue are not styles as per the WebVTT specification and are ignored, or raise ``MalformedCaptionError`` with strict parsing. Adding styles: .. code-block:: python import webvtt vtt = webvtt.read('captions.vtt') vtt.styles.append( webvtt.Style('::cue(b) {\n color: peachpuff;\n}') ) # list of lines is supported vtt.styles.append( webvtt.Style(['::cue(b) {', ' color: peachpuff;', '}' ]) ) Updating styles: .. code-block:: python import webvtt vtt = webvtt.read('captions.vtt') vtt.styles[0].lines[1] = ' color: papayawhip;' WebVTT regions -------------- Regions are areas of the video where captions are rendered, defined in ``REGION`` blocks before the first cue and used by the captions through the ``region`` cue setting. They are available in the ``regions`` list as ``Region`` objects, with a property for each setting that returns its value or ``None`` when it is not set: .. code-block:: python import webvtt from webvtt import Region vtt = webvtt.read('captions.vtt') for region in vtt.regions: print(region.id, region.width, region.lines) # change a region vtt.regions[0].width = '50%' vtt.regions[0].scroll = None # add a region and use it in a caption vtt.regions.append( Region( id='fred', width='40%', lines='3', regionanchor='0%,100%', viewportanchor='10%,90%', scroll='up' ) ) vtt.captions[0].settings.region = 'fred' The supported settings are ``id``, ``width``, ``lines``, ``regionanchor``, ``viewportanchor`` and ``scroll``. Setting a value that is not valid raises ``MalformedRegionError``, a subclass of ``MalformedCaptionError``, and the settings are saved as they were read, including the ones that are not valid or not known, with a setting per line. ``REGION`` blocks after the first cue are not regions as per the WebVTT specification and are ignored, or raise ``MalformedRegionError`` with strict parsing. When converting to SRT, the regions are not included. Removing or renaming a region does not update the captions that use it. WebVTT header ------------- The text following ``WEBVTT`` in the first line and the metadata lines after it, such as the ``X-TIMESTAMP-MAP`` used by HLS, are kept when reading and written back when saving: .. code-block:: text WEBVTT - Sample title X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:126000 Kind: captions 00:00:00.500 --> 00:00:07.000 Caption text #1 .. code-block:: python import webvtt vtt = webvtt.read('captions.vtt') vtt.header_text # '- Sample title' vtt.metadata # ['X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:126000', 'Kind: captions'] vtt.metadata.append('Language: en') Metadata lines are kept as they are, in order. Shifting or synchronizing the captions does not change them, so ``X-TIMESTAMP-MAP`` keeps mapping the file to the same media time. Saving, writing or getting the ``content`` raises ``ValueError`` if the header text has a line break or contains ``-->``, or if a metadata line is empty, has a line break or contains ``-->``, since that would break the file. Header text with ``-->`` is not valid WebVTT and is read as empty, or raises ``MalformedCaptionError`` with strict parsing. WebVTT comments --------------- Comments can be added to or retrieved from different items: .. code-block:: python import webvtt vtt = webvtt.read('captions.vtt') # comments from the top of the file print(vtt.header_comments) # comments from the bottom of the file print(vtt.footer_comments) # comments in a style print(vtt.styles[0].comments) # comments in a caption print(vtt.captions[0].comments) # comments are just a list of strings vtt.captions[5].comments.append('caption for review') A block is a comment when it starts with ``NOTE`` followed by a space, a tab or a line break. A block starting with other text, such as ``NOTEBOOK``, or containing ``-->`` is not a comment as per the WebVTT specification, so it is ignored when reading, or raises ``MalformedCaptionError`` with strict parsing. Handling errors --------------- The errors raised by webvtt-py are available in the ``webvtt.errors`` module: * ``MalformedFileError``: the content is not valid for the expected format (WebVTT, SRT or SBV). * ``MalformedCaptionError``: a caption or cue setting is not valid, or the content does not pass strict parsing. * ``MalformedRegionError``: a region setting is not valid, or a region does not pass strict parsing. It is a subclass of ``MalformedCaptionError``, so catching that error also catches it. * ``MissingFilenameError``: captions are saved without an output path and there is no file to derive it from. They all subclass ``WebVTTError``, so it catches any of them. ``MalformedFileError`` and ``MalformedCaptionError`` are also subclasses of ``ValueError``, like the other errors for values that are not valid, such as text that could not be read back when saving. Those other errors are the built-in ``ValueError`` and ``TypeError``, which ``WebVTTError`` does not catch. .. code-block:: python import webvtt from webvtt.errors import ( MalformedFileError, MalformedCaptionError, MissingFilenameError ) try: vtt = webvtt.read('captions.vtt') except MalformedFileError: print('captions.vtt is not a valid WebVTT file') except MalformedCaptionError as e: print(f'captions.vtt has an invalid caption: {e}') # an instance not read from a file needs an output path vtt = webvtt.WebVTT() try: vtt.save() except MissingFilenameError: vtt.save('captions.vtt') Parsing rules ------------- The WebVTT parser follows the parsing rules of the WebVTT specification: blocks are separated by empty lines, so lines containing only whitespace are part of the cue text, and a line containing ``-->`` starts a new block when it is not the timings line of the current one. Cues without text are kept as captions with empty text. Timestamps with fields shorter than the specification requires, such as ``0:0:13.540`` in Microsoft Teams transcripts or ``0:1:5.2``, are accepted and the captions are saved in the standard ``00:00:13.540`` and ``00:01:05.200`` format. The fraction of a second is padded on the right, so ``.2`` is read as 200 milliseconds. Strict parsing -------------- By default the parser ignores blocks it cannot parse, so a cue with malformed timings such as ``00:00:00.-004 --> 00:00:04.300`` or out of range ones such as ``00:00:61.000 --> 00:00:62.000`` is skipped. To validate the content, pass ``strict=True`` to ``read``, ``from_buffer`` or ``from_string`` and a ``MalformedCaptionError`` is raised instead. Strict parsing also raises the error for: * timestamps that are not in the WebVTT format * cues whose end time is not after the start time * cue and region settings that are not known, not valid or repeated * blocks after the header that are not a cue, comment, style or region, such as text without cue timings, or a comment that does not start with ``NOTE`` followed by a space, a tab or a line break, or contains ``-->`` * styles and regions after the first cue * a cue that is not separated from the previous cue, comment or style by an empty line For region settings and regions after the first cue, the error is ``MalformedRegionError``, a subclass of ``MalformedCaptionError``. Strict parsing also raises ``MalformedFileError`` when ``WEBVTT`` is followed by text other than a space or a tab in the first line, such as ``WEBVTTX``, which the default parsing reads. Strict parsing is only for WebVTT. SRT and SBV have no specification to validate against, so ``from_buffer`` raises ``ValueError`` for ``strict=True`` with the ``srt`` or ``sbv`` format. .. code-block:: python import webvtt from webvtt.errors import MalformedCaptionError try: vtt = webvtt.read('captions.vtt', strict=True) except MalformedCaptionError as e: print(f'captions.vtt is not valid WebVTT: {e}') glut23-webvtt-py-cca74e4/man/000077500000000000000000000000001527171777000160565ustar00rootroot00000000000000glut23-webvtt-py-cca74e4/man/webvtt.1000066400000000000000000000271561527171777000174660ustar00rootroot00000000000000.\" Man page generated from reStructuredText. . . .nr rst2man-indent-level 0 . .de1 rstReportMargin \\$1 \\n[an-margin] level \\n[rst2man-indent-level] level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] - \\n[rst2man-indent0] \\n[rst2man-indent1] \\n[rst2man-indent2] .. .de1 INDENT .\" .rstReportMargin pre: . RS \\$1 . nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin] . nr rst2man-indent-level +1 .\" .rstReportMargin post: .. .de UNINDENT . RE .\" indent \\n[an-margin] .\" old: \\n[rst2man-indent\\n[rst2man-indent-level]] .nr rst2man-indent-level -1 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. .TH "WEBVTT" "1" "Nov 01, 2026" "0.7.0" "webvtt-py" .SH NAME webvtt \- segment, shift and sync WebVTT captions .SH SYNOPSIS .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C webvtt segment PATH \-o OUTPUT [\-d NUMBER] [\-m NUMBER] [\-\-duration TIME] [\-\-overwrite] webvtt shift PATH OFFSET [\-o OUTPUT] [\-s TIMESTAMP] webvtt sync PATH (\-p FROM TO \-p FROM TO ... | \-\-fps FROM TO) [\-o OUTPUT] webvtt \-h webvtt \-\-version .ft P .fi .UNINDENT .UNINDENT .SH DESCRIPTION .sp \fBwebvtt\fP segments a WebVTT caption file for \X'tty: link https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis'\fI\%HTTP Live Streaming\fP\X'tty: link' (HLS) and shifts or synchronizes the timestamps of its captions. .sp \fBsegment\fP splits the captions in \fBPATH\fP into segments of a target duration. Each segment is written to \fBOUTPUT\fP as \fBfileSequenceN.webvtt\fP, where \fBN\fP is the segment index starting at 0, along with a playlist named \fBprog_index.m3u8\fP that lists all the segments. An \fBOUTPUT\fP with the playlist or segments of an earlier run is an error, unless \fB\-\-overwrite\fP is given. .sp A caption whose time span crosses a segment boundary is included in every segment it overlaps, so players display it regardless of which segment they load. Every segment also has the \fBSTYLE\fP and \fBREGION\fP blocks of \fBPATH\fP, which its captions use, and the captions keep their identifiers and cue settings. Comments are not written to the segments. .sp Every segment starts with an \fBX\-TIMESTAMP\-MAP\fP header that maps the local caption time \fB00:00:00.000\fP to an MPEG\-TS presentation timestamp, so the captions are kept in sync with the video stream. .sp \fBshift\fP moves the captions in \fBPATH\fP earlier or later by \fBOFFSET\fP seconds. The offset is rounded to the nearest millisecond as written, with halves rounded away from zero, so \fB1.0005\fP is 1001 milliseconds. A timestamp that would fall before zero is set to \fB00:00:00.000\fP, so a caption moved entirely before the start is kept with \fB00:00:00.000 \-\-> 00:00:00.000\fP timings. The shifted captions are written to standard output in UTF\-8, or to \fBOUTPUT\fP when given. .sp \fBsync\fP fixes captions that drift further out of sync along the video. With \fB\-p\fP, each point moves the time \fBFROM\fP in the captions to \fBTO\fP; times between two points are moved along the line between them, and times before the first or after the last point along the line of the outermost points. With \fB\-\-fps\fP, the timing is changed from one frame rate to another. Times are rounded and set to zero like with \fBshift\fP, and the output is written the same way. .sp The commands can also be run as \fBpython \-m webvtt\fP, as in \fBpython \-m webvtt shift captions.vtt 2\fP\&. .sp \fBsegment\fP, \fBshift\fP and \fBsync\fP read the captions from standard input when \fBPATH\fP is \fB\-\fP, decoded as UTF\-8 as WebVTT requires, so they can be used in pipes. A file named \fB\-\fP can be read as \fB\&./\-\fP\&. .SH COMMANDS .INDENT 0.0 .TP .B \fBsegment\fP Segment the WebVTT file at \fBPATH\fP\&. .TP .B \fBshift\fP Shift the timestamps of the WebVTT file at \fBPATH\fP\&. .TP .B \fBsync\fP Synchronize the timestamps of the WebVTT file at \fBPATH\fP\&. .UNINDENT .SH OPTIONS .INDENT 0.0 .TP .B \fB\-h\fP, \fB\-\-help\fP Show the help message and exit. Also available for each command, as in \fBwebvtt shift \-h\fP\&. .TP .B \fB\-\-version\fP Show the version of webvtt\-py and exit. .UNINDENT .SS Segment options .INDENT 0.0 .TP .B \fB\-o OUTPUT\fP, \fB\-\-output OUTPUT\fP Directory where the segments and playlist are written. It is created if it does not exist. Required. .TP .B \fB\-d NUMBER\fP, \fB\-\-target\-duration NUMBER\fP Target duration of each segment in seconds, a whole number above zero. Also used for the \fB#EXT\-X\-TARGETDURATION\fP value of the playlist and the \fB#EXTINF\fP of its segments, except the last with \fB\-\-duration\fP\&. Default: \fB10\fP\&. .TP .B \fB\-m NUMBER\fP, \fB\-\-mpegts NUMBER\fP MPEG\-TS value written to the \fBX\-TIMESTAMP\-MAP\fP header of each segment, a whole number from 0 to 8589934591, as MPEG\-TS timestamps have 33 bits. Default: \fB900000\fP (10 seconds at the 90 kHz MPEG\-TS clock). .TP .B \fB\-\-duration TIME\fP Length of the media, as a timestamp such as \fB01:25.500\fP or a number of seconds such as \fB85.5\fP\&. The playlist then covers all the media, with empty segments after the last caption, and the \fB#EXTINF\fP of its last segment ends with the media. A caption starting at or after it is an error. Without it, the playlist ends with the segment of the last caption, and every segment lasts the target duration. .TP .B \fB\-\-overwrite\fP Replace the playlist and segments of an earlier run in \fBOUTPUT\fP, and delete the segments of that run that the new playlist does not have. Other files in \fBOUTPUT\fP are left alone. Without it, an \fBOUTPUT\fP with a \fBprog_index.m3u8\fP or \fBfileSequenceN.webvtt\fP file is an error. .UNINDENT .SS Shift options .INDENT 0.0 .TP .B \fBOFFSET\fP Number of seconds to shift the captions by, such as \fB2\fP or \fB0.25\fP\&. Negative values move the captions earlier, as in \fB\-1.5\fP\&. Required. .TP .B \fB\-o OUTPUT\fP, \fB\-\-output OUTPUT\fP File where the shifted captions are written, keeping the encoding and Byte Order Mark of the input file. It can be the input file itself to shift it in place. A \fB\&.vtt\fP extension is added if it has no extension, and if it is a directory the file is written there with the input file name, which is an error when reading standard input. Default: standard output. .TP .B \fB\-s TIMESTAMP\fP, \fB\-\-start TIMESTAMP\fP Only shift the captions starting at or after \fBTIMESTAMP\fP, in \fB[hh:]mm:ss.mmm\fP format. Default: shift all the captions. .UNINDENT .SS Sync options .INDENT 0.0 .TP .B \fB\-p FROM TO\fP, \fB\-\-point FROM TO\fP Move the time \fBFROM\fP in the captions to \fBTO\fP\&. Each time is a timestamp in \fB[hh:]mm:ss.mmm\fP format or a number of seconds. Give it two or more times, in any order. Two points cannot have the same \fBFROM\fP, and the \fBTO\fP times must be in the same order as the \fBFROM\fP times. .TP .B \fB\-\-fps FROM TO\fP Change the timing from the frame rate \fBFROM\fP the captions are timed for to the frame rate \fBTO\fP of the video, such as \fB25 23.976\fP\&. A frame rate can also be a ratio, such as \fB24000/1001\fP for the exact NTSC rate. Times are multiplied by \fBFROM / TO\fP\&. Cannot be used with \fB\-p\fP\&. .TP .B \fB\-o OUTPUT\fP, \fB\-\-output OUTPUT\fP File where the synchronized captions are written, as with \fBshift\fP\&. Default: standard output. .UNINDENT .SH EXIT STATUS .INDENT 0.0 .TP .B \fB0\fP The command completed successfully. .TP .B \fB1\fP The input could not be read or is not a valid WebVTT file, the captions could not be moved as asked, or the output could not be written. For \fBsegment\fP, it is also an error when the output directory has the files of an earlier run without \fB\-\-overwrite\fP, or when a caption starts at or after \fB\-\-duration\fP\&. An error message is printed to standard error, for example: .INDENT 7.0 .INDENT 3.5 .sp .nf .ft C webvtt: error: captions.vtt: not a valid WebVTT file .ft P .fi .UNINDENT .UNINDENT .TP .B \fB2\fP Invalid command line arguments, such as an offset that is not a number, an invalid \fB\-\-start\fP timestamp or sync points that cannot be used. .UNINDENT .SH EXAMPLES .sp Given \fBcaptions.vtt\fP: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C WEBVTT 00:00:01.000 \-\-> 00:00:04.000 Hello and welcome. 00:00:06.000 \-\-> 00:00:12.000 This caption spans two segments. 00:00:14.500 \-\-> 00:00:18.000 Thanks for watching! .ft P .fi .UNINDENT .UNINDENT .sp Segment it into 10\-second segments: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C $ webvtt segment captions.vtt \-\-output out $ ls out fileSequence0.webvtt fileSequence1.webvtt prog_index.m3u8 .ft P .fi .UNINDENT .UNINDENT .sp \fBout/prog_index.m3u8\fP: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C #EXTM3U #EXT\-X\-TARGETDURATION:10 #EXT\-X\-VERSION:3 #EXT\-X\-PLAYLIST\-TYPE:VOD #EXTINF:10.00000, fileSequence0.webvtt #EXTINF:10.00000, fileSequence1.webvtt #EXT\-X\-ENDLIST .ft P .fi .UNINDENT .UNINDENT .sp \fBout/fileSequence0.webvtt\fP: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C WEBVTT X\-TIMESTAMP\-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:01.000 \-\-> 00:00:04.000 Hello and welcome. 00:00:06.000 \-\-> 00:00:12.000 This caption spans two segments. .ft P .fi .UNINDENT .UNINDENT .sp \fBout/fileSequence1.webvtt\fP: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C WEBVTT X\-TIMESTAMP\-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:06.000 \-\-> 00:00:12.000 This caption spans two segments. 00:00:14.500 \-\-> 00:00:18.000 Thanks for watching! .ft P .fi .UNINDENT .UNINDENT .sp Segment it again into \fBout\fP, with 6\-second segments and a different MPEG\-TS value, replacing the earlier segments: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C $ webvtt segment captions.vtt \-o out webvtt: error: out: has the files of an earlier segmentation, such as fileSequence0.webvtt; use \-\-overwrite to replace them $ webvtt segment captions.vtt \-o out \-d 6 \-m 0 \-\-overwrite .ft P .fi .UNINDENT .UNINDENT .sp Show the captions 1.5 seconds earlier: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C $ webvtt shift captions.vtt \-1.5 WEBVTT 00:00:00.000 \-\-> 00:00:02.500 Hello and welcome. 00:00:04.500 \-\-> 00:00:10.500 This caption spans two segments. 00:00:13.000 \-\-> 00:00:16.500 Thanks for watching! .ft P .fi .UNINDENT .UNINDENT .sp Show the last caption 2 seconds later, updating the file in place: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C $ webvtt shift captions.vtt 2 \-\-start 00:14.500 \-o captions.vtt .ft P .fi .UNINDENT .UNINDENT .sp Keep the first caption in place and move the last one from 00:14.500 to 00:15.850, synchronizing the captions in between: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C $ webvtt sync captions.vtt \-p 00:01.000 00:01.000 \-p 00:14.500 00:15.850 WEBVTT 00:00:01.000 \-\-> 00:00:04.300 Hello and welcome. 00:00:06.500 \-\-> 00:00:13.100 This caption spans two segments. 00:00:15.850 \-\-> 00:00:19.700 Thanks for watching! .ft P .fi .UNINDENT .UNINDENT .sp Fix captions timed for a 25 fps video played with a 23.976 fps one, updating the file in place: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C $ webvtt sync captions.vtt \-\-fps 25 23.976 \-o captions.vtt .ft P .fi .UNINDENT .UNINDENT .sp Shift and synchronize the captions in a pipe, reading them from standard input: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C $ webvtt shift captions.vtt 2 | webvtt sync \- \-\-fps 25 23.976 > fixed.vtt .ft P .fi .UNINDENT .UNINDENT .sp Synchronize the captions and segment them, without a file in between: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C $ webvtt sync captions.vtt \-\-fps 25 23.976 | webvtt segment \- \-o out .ft P .fi .UNINDENT .UNINDENT .SH SEE ALSO .sp The same segmentation, shifting and synchronizing are available from Python with \fBwebvtt.segment()\fP, \fBWebVTT.shift()\fP, \fBWebVTT.sync()\fP and \fBWebVTT.change_framerate()\fP\&. .SH AUTHOR Alejandro Mendez .SH COPYRIGHT 2016-2026, Alejandro Mendez .\" Generated by docutils manpage writer. . glut23-webvtt-py-cca74e4/setup.cfg000066400000000000000000000014211527171777000171220ustar00rootroot00000000000000[metadata] description_file = README.rst [flake8] doctests = true radon-max-cc=10 [tox:tox] envlist = codestyle py coverage isolated_build = True [coverage:run] source = webvtt branch = true [coverage:report] fail_under = 100 [testenv] # create the environments with a current pip instead of the one bundled with # virtualenv, as pip before 26.2.2 trusts the certificates of the # eMudhra/emSign CA incident download = true deps = coverage description = run the tests and provide coverage metrics commands = coverage run -m unittest discover [testenv:codestyle] deps = flake8 flake8-docstrings radon mypy commands = flake8 webvtt setup.py mypy webvtt [testenv:coverage] deps = coverage commands = coverage xml coverage report glut23-webvtt-py-cca74e4/setup.py000066400000000000000000000030611527171777000170150ustar00rootroot00000000000000"""webvtt-py setuptools configuration.""" import re import pathlib from setuptools import setup, find_packages version, author, author_email = ( re.search( rf'__{name}__ = \'(.*?)\'', pathlib.Path('webvtt/__init__.py').read_text() ).group(1) for name in ('version', 'author', 'author_email') ) setup( name='webvtt-py', version=version, description='WebVTT reader, writer and segmenter', long_description=pathlib.Path('README.rst').read_text(), long_description_content_type='text/x-rst', author=author, author_email=author_email, url='https://github.com/glut23/webvtt-py', packages=find_packages('.', exclude=['tests']), include_package_data=True, data_files=[('share/man/man1', ['man/webvtt.1'])], entry_points={ 'console_scripts': [ 'webvtt=webvtt.cli:main' ] }, license='MIT', python_requires='>=3.10', classifiers=[ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3.14', 'Programming Language :: Python :: 3.15', 'Operating System :: OS Independent', 'Topic :: Software Development :: Libraries :: Python Modules', ], keywords='webvtt captions', ) glut23-webvtt-py-cca74e4/tests/000077500000000000000000000000001527171777000164455ustar00rootroot00000000000000glut23-webvtt-py-cca74e4/tests/__init__.py000066400000000000000000000000001527171777000205440ustar00rootroot00000000000000glut23-webvtt-py-cca74e4/tests/helpers.py000066400000000000000000000010171527171777000204600ustar00rootroot00000000000000import contextlib import os import tempfile @contextlib.contextmanager def named_temporary_file(*args, **kwargs): """ Return a named temporary file that can be opened again by its name. On Windows, a file of tempfile.NamedTemporaryFile cannot be opened again while it is open, so it is created with delete=False and removed when the block ends. """ f = tempfile.NamedTemporaryFile(*args, delete=False, **kwargs) try: with f: yield f finally: os.remove(f.name) glut23-webvtt-py-cca74e4/tests/samples/000077500000000000000000000000001527171777000201115ustar00rootroot00000000000000glut23-webvtt-py-cca74e4/tests/samples/captions_with_bom.vtt000066400000000000000000000003051527171777000243560ustar00rootroot00000000000000WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:11.890 Caption text #2 00:00:11.890 --> 00:00:16.320 Caption text #3 00:00:16.320 --> 00:00:21.580 Caption text #4glut23-webvtt-py-cca74e4/tests/samples/comments.vtt000066400000000000000000000006131527171777000224750ustar00rootroot00000000000000WEBVTT - Translation of that film I like NOTE This translation was done by Kyle so that some friends can watch it with their parents. 1 00:02:15.000 --> 00:02:20.000 - Ta en kopp varmt te. - Det är inte varmt. 2 00:02:20.000 --> 00:02:25.000 - Har en kopp te. - Det smakar som te. NOTE This last line may not translate well. 3 00:02:25.000 --> 00:02:30.000 - Ta en kopp NOTE end of fileglut23-webvtt-py-cca74e4/tests/samples/cue_tags.vtt000066400000000000000000000003521527171777000224420ustar00rootroot00000000000000WEBVTT 00:16.500 --> 00:18.500 When the moon <00:17.500>hits your eye 00:00:18.500 --> 00:00:20.500 Like a <00:19.000>big-a <00:19.500>pizza <00:20.000>pie 00:00:20.500 --> 00:00:21.500 That's <00:00:21.000>amoreglut23-webvtt-py-cca74e4/tests/samples/empty.vtt000066400000000000000000000000001527171777000217740ustar00rootroot00000000000000glut23-webvtt-py-cca74e4/tests/samples/invalid.vtt000066400000000000000000000005071527171777000223000ustar00rootroot0000000000000000:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:11.890 Caption text #2 00:00:11.890 --> 00:00:16.320 Caption text #3 00:00:16.320 --> 00:00:21.580 Caption text #4 00:00:21.580 --> 00:00:23.880 Caption text #5 00:00:23.880 --> 00:00:27.280 Caption text #6 00:00:27.280 --> 00:00:30.280 Caption text #7glut23-webvtt-py-cca74e4/tests/samples/invalid_format.sbv000066400000000000000000000000111527171777000236130ustar00rootroot00000000000000some textglut23-webvtt-py-cca74e4/tests/samples/invalid_format1.srt000066400000000000000000000000011527171777000237110ustar00rootroot000000000000001glut23-webvtt-py-cca74e4/tests/samples/invalid_format2.srt000066400000000000000000000000111527171777000237130ustar00rootroot00000000000000some textglut23-webvtt-py-cca74e4/tests/samples/invalid_format3.srt000066400000000000000000000000131527171777000237160ustar00rootroot000000000000001 some textglut23-webvtt-py-cca74e4/tests/samples/invalid_format4.srt000066400000000000000000000000471527171777000237260ustar00rootroot00000000000000some text 00:00:00,500 --> 00:00:07,000glut23-webvtt-py-cca74e4/tests/samples/invalid_timeframe.sbv000066400000000000000000000003131527171777000243010ustar00rootroot000000000000000:00:00.378,0:00:11.378 Caption text #1 0:00:11.378,0:00:12.305 Caption text #2 0:00:13.f89,0:00:15.489 Caption text #3 0:00:15.489,0:00:16.234 Caption text #4 0:00:17.484,0:00:16.543 Caption text #5glut23-webvtt-py-cca74e4/tests/samples/invalid_timeframe.srt000066400000000000000000000003631527171777000243240ustar00rootroot000000000000001 00:00:00,500 --> 00:00:07,000 Caption text #1 2 00:00:07,000 --> 00:00:11:890 Caption text #2 3 00:00:11,890 --> 00:00:16,320 Caption text #3 4 00:00:16,320 --> 00:00:21,580 Caption text #4 5 00:00:21,580 --> 00:00:23,880 Caption text #5glut23-webvtt-py-cca74e4/tests/samples/invalid_timeframe.vtt000066400000000000000000000005171527171777000243320ustar00rootroot00000000000000WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:1d.890 Caption text #2 00:00:11.890 --> 00:00:16.320 Caption text #3 00:00:16.320 --> 00:00:21.580 Caption text #4 00:00:21.580 --> 00:00:23.880 Caption text #5 00:00:23.880 --> 00:00:27.280 Caption text #6 00:00:27.280 --> 00:00:30.280 Caption text #7glut23-webvtt-py-cca74e4/tests/samples/invalid_timeframe_in_cue_text.vtt000066400000000000000000000002611527171777000267140ustar00rootroot00000000000000WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:10.890 00:00:07.000 --> 00:00:10.890 Caption text #2 00:00:11.890 --> 00:00:16.320 Caption text #3glut23-webvtt-py-cca74e4/tests/samples/metadata_headers.vtt000066400000000000000000000002001527171777000241130ustar00rootroot00000000000000WEBVTT Kind: captions Language: en 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:11.890 Caption text #2glut23-webvtt-py-cca74e4/tests/samples/metadata_headers_multiline.vtt000066400000000000000000000002761527171777000262120ustar00rootroot00000000000000WEBVTT Kind: captions Language: en Style: ::cue(c.colorCCCCCC) { color: rgb(204, 204, 204); } ## 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:11.890 Caption text #2glut23-webvtt-py-cca74e4/tests/samples/missing_caption_text.sbv000066400000000000000000000002731527171777000250610ustar00rootroot000000000000000:00:00.378,0:00:11.378 Caption text #1 0:00:11.378,0:00:12.305 0:00:13.489,0:00:15.489 Caption text #3 0:00:15.489,0:00:16.234 Caption text #4 0:00:17.484,0:00:16.543 Caption text #5glut23-webvtt-py-cca74e4/tests/samples/missing_caption_text.srt000066400000000000000000000003441527171777000250760ustar00rootroot000000000000001 00:00:00,500 --> 00:00:07,000 Caption text #1 2 00:00:07,000 --> 00:00:11,890 3 00:00:11,890 --> 00:00:16,320 Caption text #3 4 00:00:16,320 --> 00:00:21,580 Caption text #4 5 00:00:21,580 --> 00:00:23,880 Caption text #5glut23-webvtt-py-cca74e4/tests/samples/missing_caption_text.vtt000066400000000000000000000003411527171777000251000ustar00rootroot00000000000000WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:11.890 00:00:11.890 --> 00:00:16.320 Caption text #3 00:00:16.320 --> 00:00:21.580 Caption text #4 00:00:21.580 --> 00:00:23.880 Caption text #5glut23-webvtt-py-cca74e4/tests/samples/missing_timeframe.sbv000066400000000000000000000002631527171777000243300ustar00rootroot000000000000000:00:00.378,0:00:11.378 Caption text #1 Caption text #2 0:00:13.489,0:00:15.489 Caption text #3 0:00:15.489,0:00:16.234 Caption text #4 0:00:17.484,0:00:16.543 Caption text #5glut23-webvtt-py-cca74e4/tests/samples/missing_timeframe.srt000066400000000000000000000003251527171777000243450ustar00rootroot000000000000001 00:00:00,500 --> 00:00:07,000 Caption text #1 2 Caption text #2 3 00:00:11,890 --> 00:00:16,320 Caption text #3 4 00:00:16,320 --> 00:00:21,580 Caption text #4 5 00:00:21,580 --> 00:00:23,880 Caption text #5glut23-webvtt-py-cca74e4/tests/samples/missing_timeframe.vtt000066400000000000000000000004611527171777000243530ustar00rootroot00000000000000WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 Caption text #2 00:00:11.890 --> 00:00:16.320 Caption text #3 00:00:16.320 --> 00:00:21.580 Caption text #4 00:00:21.580 --> 00:00:23.880 Caption text #5 00:00:23.880 --> 00:00:27.280 Caption text #6 00:00:27.280 --> 00:00:30.280 Caption text #7glut23-webvtt-py-cca74e4/tests/samples/netflix_chicas_del_cable.vtt000077500000000000000000003526561527171777000256310ustar00rootroot00000000000000WEBVTT NOTE Netflix NOTE Profile: webvtt-lssdh-ios8 NOTE Date: 2017/04/16 15:44:03 NOTE SegmentIndex NOTE Segment=597.080 20887@419 153 NOTE Segment=592.000 23822@21306 173 NOTE Segment=572.880 25784@45128 183 NOTE Segment=575.160 18267@70912 131 NOTE Segment=591.040 26386@89179 191 NOTE Segment=220.440 4676@115565 34 NOTE /SegmentIndex 1 00:00:07.960 --> 00:00:09.480 position:50.00%,middle align:middle size:80.00% line:84.67% [Alba] En 1928, 2 00:00:09.640 --> 00:00:13.080 position:50.00%,middle align:middle size:80.00% line:79.33% las mujeres éramos algo así como adornos 3 00:00:13.240 --> 00:00:16.440 position:50.00%,middle align:middle size:80.00% line:79.33% que se llevaban a las fiestas para presumir de ellos. 4 00:00:19.720 --> 00:00:22.160 position:50.00%,middle align:middle size:80.00% line:84.67% Objetos sin poder de opinión ni decisión. 5 00:00:22.640 --> 00:00:24.760 position:50.00%,middle align:middle size:80.00% line:84.67% Es cierto que la vida no era fácil 6 00:00:24.920 --> 00:00:27.680 position:50.00%,middle align:middle size:80.00% line:79.33% para nadie, pero mucho menos si eras mujer. 7 00:00:29.320 --> 00:00:32.200 position:50.00%,middle align:middle size:80.00% line:84.67% Si eras mujer, en 1928 8 00:00:32.360 --> 00:00:35.160 position:50.00%,middle align:middle size:80.00% line:79.33% ser libre era algo que parecía inalcanzable. 9 00:00:35.320 --> 00:00:36.880 position:50.00%,middle align:middle size:80.00% line:84.67% Porque para la sociedad 10 00:00:37.040 --> 00:00:41.040 position:50.00%,middle align:middle size:80.00% line:79.33% las mujeres solo éramos amas de casa, madres, esposas... 11 00:00:42.000 --> 00:00:45.080 position:50.00%,middle align:middle size:80.00% line:79.33% No teníamos derecho a tener sueños ni ambiciones. 12 00:00:45.240 --> 00:00:49.800 position:50.00%,middle align:middle size:80.00% line:79.33% Para buscar un futuro muchas tenían que marcharse lejos. 13 00:00:49.960 --> 00:00:51.240 position:50.00%,middle align:middle size:80.00% line:84.67% En el bar de enfrente. 14 00:00:52.360 --> 00:00:53.400 position:50.00%,middle align:middle size:80.00% line:84.67% Tú no vas a ir 15 00:00:53.560 --> 00:00:54.600 position:50.00%,middle align:middle size:80.00% line:84.67% a ningún sitio. 16 00:00:54.760 --> 00:00:56.480 position:50.00%,middle align:middle size:80.00% line:84.67% Y otras tenían que enfrentarse 17 00:00:56.640 --> 00:00:59.480 position:50.00%,middle align:middle size:80.00% line:79.33% a las normas de una sociedad machista y retrógrada. 18 00:00:59.640 --> 00:01:02.000 position:50.00%,middle align:middle size:80.00% line:79.33% - Déjala. - Tú no te metas. ¡No te metas! 19 00:01:02.160 --> 00:01:06.600 position:50.00%,middle align:middle size:80.00% line:79.33% Al final, todas, ricas, pobres, queríamos lo mismo: 20 00:01:06.760 --> 00:01:09.160 position:50.00%,middle align:middle size:80.00% line:84.67% ser libres. Y si para eso 21 00:01:09.320 --> 00:01:11.960 position:50.00%,middle align:middle size:80.00% line:79.33% había que quebrantar la ley, estábamos dispuestas 22 00:01:12.120 --> 00:01:15.560 position:50.00%,middle align:middle size:80.00% line:79.33% a hacerlo sin importarnos las consecuencias. 23 00:01:21.960 --> 00:01:23.520 position:50.00%,middle align:middle size:80.00% line:84.67% Solo las que luchan por ellos 24 00:01:23.680 --> 00:01:25.680 position:50.00%,middle align:middle size:80.00% line:84.67% consiguen sus sueños. 25 00:01:36.560 --> 00:01:37.920 position:50.00%,middle align:middle size:80.00% line:84.67% Eso creíamos. 26 00:01:38.080 --> 00:01:39.840 position:50.00%,middle align:middle size:80.00% line:84.67% Lo que no sabíamos era 27 00:01:40.000 --> 00:01:42.440 position:50.00%,middle align:middle size:80.00% line:84.67% que el destino nos tenía preparadas 28 00:01:42.600 --> 00:01:44.040 position:50.00%,middle align:middle size:80.00% line:84.67% muchas sorpresas. 29 00:01:46.520 --> 00:01:47.600 position:50.00%,middle align:middle size:80.00% line:84.67% Vámonos. 30 00:01:47.760 --> 00:01:48.800 position:50.00%,middle align:middle size:80.00% line:84.67% Rápido. 31 00:01:50.040 --> 00:01:51.800 position:50.00%,middle align:middle size:80.00% line:84.67% Argentina nos espera. 32 00:01:51.960 --> 00:01:54.120 position:50.00%,middle align:middle size:80.00% line:84.67% [ríen emocionadas] 33 00:01:54.280 --> 00:01:55.400 position:50.00%,middle align:middle size:80.00% line:84.67% ¡Corre! 34 00:01:56.400 --> 00:01:59.800 position:50.00%,middle align:middle size:80.00% line:79.33% - Nunca has montado en barco. - Así que pensabais escaparos... 35 00:02:00.560 --> 00:02:03.560 position:50.00%,middle align:middle size:80.00% line:79.33% ¡Ni se te ocurra moverte! Esto es entre Gimena y yo. 36 00:02:03.720 --> 00:02:06.640 position:50.00%,middle align:middle size:80.00% line:79.33% - Mi amiga no quiere volver a verte. - Que me lo diga ella. 37 00:02:06.800 --> 00:02:08.800 position:50.00%,middle align:middle size:80.00% line:84.67% Como grites, te juro que te mato. 38 00:02:08.960 --> 00:02:10.560 position:50.00%,middle align:middle size:80.00% line:84.67% Tranquilízate, por favor. 39 00:02:10.720 --> 00:02:13.840 position:50.00%,middle align:middle size:80.00% line:79.33% [Pedro] Lo haré cuando le digas a tu amiga que te vienes conmigo. 40 00:02:14.000 --> 00:02:15.880 position:50.00%,middle align:middle size:80.00% line:84.67% No se va a ir contigo. 41 00:02:16.040 --> 00:02:19.920 position:50.00%,middle align:middle size:80.00% line:79.33% Va a hacer lo que yo diga. Si no, primero la mato a ella y luego a ti. 42 00:02:20.080 --> 00:02:23.040 position:50.00%,middle align:middle size:80.00% line:79.33% Está bien, haré lo que quieras, pero baja el arma. 43 00:02:23.200 --> 00:02:24.480 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Sí? 44 00:02:29.520 --> 00:02:31.040 position:50.00%,middle align:middle size:80.00% line:84.67% [grita con rabia] 45 00:02:31.200 --> 00:02:32.360 position:50.00%,middle align:middle size:80.00% line:84.67% [dos disparos] 46 00:02:32.520 --> 00:02:33.720 position:50.00%,middle align:middle size:80.00% line:84.67% ¡Ah! 47 00:02:37.880 --> 00:02:39.920 position:50.00%,middle align:middle size:80.00% line:84.67% [respiración jadeante] 48 00:02:41.920 --> 00:02:45.560 position:50.00%,middle align:middle size:80.00% line:84.67% [respira agitada] 49 00:02:51.040 --> 00:02:52.240 position:50.00%,middle align:middle size:80.00% line:84.67% Gimena... 50 00:02:52.400 --> 00:02:53.840 position:50.00%,middle align:middle size:80.00% line:84.67% Gimena, no, por favor. 51 00:02:54.560 --> 00:02:58.240 position:50.00%,middle align:middle size:80.00% line:84.67% Mírame. Tú no tenías que estar aquí. 52 00:02:58.400 --> 00:03:00.360 position:50.00%,middle align:middle size:80.00% line:79.33% - No debería haberte traído. - Chist. 53 00:03:00.520 --> 00:03:02.760 position:50.00%,middle align:middle size:80.00% line:79.33% - Lo siento, lo siento. - Era nuestro sueño. 54 00:03:02.920 --> 00:03:04.680 position:50.00%,middle align:middle size:80.00% line:84.67% Es, es nuestro sueño. 55 00:03:05.520 --> 00:03:07.000 position:50.00%,middle align:middle size:80.00% line:84.67% Lo sigue siendo. 56 00:03:07.160 --> 00:03:08.920 position:50.00%,middle align:middle size:80.00% line:79.33% - Mujeres libres... - Sí, sí. 57 00:03:11.640 --> 00:03:13.640 position:50.00%,middle align:middle size:80.00% line:84.67% [llora] 58 00:03:22.520 --> 00:03:24.520 position:50.00%,middle align:middle size:80.00% line:84.67% [truenos] 59 00:03:41.760 --> 00:03:45.000 position:50.00%,middle align:middle size:80.00% line:79.33% [Beltrán] Así que estabas en la calle con dos cadáveres, 60 00:03:45.160 --> 00:03:47.160 position:50.00%,middle align:middle size:80.00% line:84.67% pero no tienes nada que ver. 61 00:03:50.760 --> 00:03:52.640 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Y por qué debería creerte? 62 00:03:52.800 --> 00:03:55.480 position:50.00%,middle align:middle size:80.00% line:79.33% - Porque digo la verdad. - Alba Romero, 63 00:03:55.640 --> 00:03:59.280 position:50.00%,middle align:middle size:80.00% line:79.33% 25 años, antecedentes de hurto y estafa. 64 00:03:59.440 --> 00:04:01.480 position:50.00%,middle align:middle size:80.00% line:84.67% Y ahora además asesinato... 65 00:04:02.280 --> 00:04:05.080 position:50.00%,middle align:middle size:80.00% line:79.33% ¿Sabes lo que les pasa a los acusados de asesinato? 66 00:04:05.640 --> 00:04:07.040 position:50.00%,middle align:middle size:80.00% line:84.67% El garrote vil. 67 00:04:07.200 --> 00:04:08.440 position:50.00%,middle align:middle size:80.00% line:84.67% [solloza] 68 00:04:08.600 --> 00:04:11.200 position:50.00%,middle align:middle size:80.00% line:79.33% A no ser que colaboren con la justicia... 69 00:04:19.000 --> 00:04:20.279 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Cómo? 70 00:04:20.440 --> 00:04:23.280 position:50.00%,middle align:middle size:80.00% line:79.33% Pues haciendo lo que mejor sabes hacer. 71 00:04:24.840 --> 00:04:25.920 position:50.00%,middle align:middle size:80.00% line:84.67% Robar. 72 00:04:27.280 --> 00:04:28.760 position:50.00%,middle align:middle size:80.00% line:84.67% Pero ahora para mí. 73 00:04:29.400 --> 00:04:30.680 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Qué me dices? 74 00:04:34.520 --> 00:04:36.520 position:50.00%,middle align:middle size:80.00% line:84.67% [tema musical] 75 00:05:34.600 --> 00:05:38.320 position:50.00%,middle align:middle size:80.00% line:79.33% [Alba] Y allí estaba: el nuevo rascacielos de la única compañía 76 00:05:38.480 --> 00:05:40.120 position:50.00%,middle align:middle size:80.00% line:84.67% de telefonía del país. 77 00:05:41.080 --> 00:05:44.080 position:50.00%,middle align:middle size:80.00% line:79.33% 13 plantas, más de 30 millones de metros de cable, 78 00:05:44.240 --> 00:05:45.680 position:50.00%,middle align:middle size:80.00% line:84.67% miles de trabajadores: 79 00:05:45.840 --> 00:05:50.840 position:50.00%,middle align:middle size:80.00% line:10.00% ejecutivos, telefonistas, 800 puestos de trabajo libres 80 00:05:51.000 --> 00:05:52.400 position:50.00%,middle align:middle size:80.00% line:10.00% y una caja fuerte 81 00:05:52.560 --> 00:05:54.920 position:50.00%,middle align:middle size:80.00% line:10.00% llena de dinero que robar. 82 00:05:55.080 --> 00:05:56.720 position:50.00%,middle align:middle size:80.00% line:10.00% [Sara] Por favor, señoritas... 83 00:05:57.560 --> 00:05:59.840 position:50.00%,middle align:middle size:80.00% line:10.00% Bienvenidas a la nueva sede central 84 00:06:00.000 --> 00:06:01.640 position:50.00%,middle align:middle size:80.00% line:10.00% de la Compañía de Telefonía. 85 00:06:01.800 --> 00:06:05.400 position:50.00%,middle align:middle size:80.00% line:10.00% Disculpen tanto ir y venir, aún estamos ultimando la mudanza. 86 00:06:05.560 --> 00:06:08.520 position:50.00%,middle align:middle size:80.00% line:10.00% Desde hoy, muchos de los servicios de la compañía 87 00:06:08.680 --> 00:06:11.800 position:50.00%,middle align:middle size:80.00% line:10.00% y las conexiones telefónicas se han centralizado aquí, 88 00:06:11.960 --> 00:06:14.240 position:50.00%,middle align:middle size:80.00% line:84.67% así que les pediría que presten atención. 89 00:06:14.400 --> 00:06:16.960 position:50.00%,middle align:middle size:80.00% line:79.33% Las candidatas para los puestos a telefonistas, 90 00:06:17.120 --> 00:06:18.400 position:50.00%,middle align:middle size:80.00% line:84.67% a la izquierda, en fila, 91 00:06:18.560 --> 00:06:20.760 position:50.00%,middle align:middle size:80.00% line:84.67% y vayan diciéndome sus nombres. 92 00:06:20.920 --> 00:06:24.120 position:50.00%,middle align:middle size:80.00% line:79.33% Bienvenida a la Compañía de Telefonía, ¿puedo ayudarla? 93 00:06:24.280 --> 00:06:27.400 position:50.00%,middle align:middle size:80.00% line:79.33% - Buenas, vengo para las pruebas... - De telefonista supongo. 94 00:06:27.560 --> 00:06:29.200 position:50.00%,middle align:middle size:80.00% line:79.33% - Sí. - Si me dice su nombre, la busco. 95 00:06:29.360 --> 00:06:30.360 position:50.00%,middle align:middle size:80.00% line:79.33% - Lidia. - Lidia... 96 00:06:30.520 --> 00:06:32.200 position:50.00%,middle align:middle size:80.00% line:79.33% - Lidia Aguilar. - Aguilar. 97 00:06:32.360 --> 00:06:35.800 position:50.00%,middle align:middle size:80.00% line:79.33% Pues lo lamento, no se encuentra entre las preseleccionadas. 98 00:06:35.960 --> 00:06:39.240 position:50.00%,middle align:middle size:80.00% line:79.33% ¿Preseleccionadas? Pensaba que con presentarnos hoy... 99 00:06:39.400 --> 00:06:41.920 position:50.00%,middle align:middle size:80.00% line:79.33% No es la única, ha habido un aluvión de solicitudes 100 00:06:42.080 --> 00:06:44.120 position:50.00%,middle align:middle size:80.00% line:84.67% y ha habido que hacer una preselección. 101 00:06:44.280 --> 00:06:45.880 position:50.00%,middle align:middle size:80.00% line:84.67% Lo siento muchísimo, Lidia. 102 00:06:46.040 --> 00:06:47.760 position:50.00%,middle align:middle size:80.00% line:79.33% - Gracias por venir. - Vaya... 103 00:06:47.920 --> 00:06:51.480 position:50.00%,middle align:middle size:80.00% line:79.33% - ¿Su nombre es? - Lidia Aguilar. 104 00:06:54.720 --> 00:06:56.760 position:50.00%,middle align:middle size:80.00% line:79.33% Las pruebas darán comienzo a las 12 en punto. 105 00:06:56.920 --> 00:06:59.840 position:50.00%,middle align:middle size:80.00% line:79.33% Quien no esté dentro de la sala no podrá acceder a las pruebas. 106 00:07:00.000 --> 00:07:02.120 position:50.00%,middle align:middle size:80.00% line:84.67% Hay 390 solicitudes para 12 plazas. 107 00:07:02.280 --> 00:07:04.640 position:50.00%,middle align:middle size:80.00% line:84.67% 12 afortunadas que, les puedo asegurar, 108 00:07:04.800 --> 00:07:07.240 position:50.00%,middle align:middle size:80.00% line:84.67% conseguirán el mejor trabajo de sus vidas, 109 00:07:07.400 --> 00:07:08.760 position:50.00%,middle align:middle size:80.00% line:84.67% así que no se despisten. 110 00:07:08.920 --> 00:07:10.440 position:50.00%,middle align:middle size:80.00% line:84.67% Acompáñenme, por favor. 111 00:07:11.400 --> 00:07:12.640 position:50.00%,middle align:middle size:80.00% line:84.67% Están ustedes 112 00:07:12.800 --> 00:07:14.920 position:50.00%,middle align:middle size:80.00% line:84.67% en el edificio más moderno de Madrid. 113 00:07:15.080 --> 00:07:17.920 position:50.00%,middle align:middle size:80.00% line:79.33% 13 plantas dedicadas a la compañía de teléfonos. 114 00:07:18.080 --> 00:07:20.960 position:50.00%,middle align:middle size:80.00% line:79.33% En la planta uno podrán encontrar el Servicio de Atención al Público 115 00:07:21.120 --> 00:07:22.800 position:50.00%,middle align:middle size:80.00% line:84.67% y el Departamento Comercial. 116 00:07:22.960 --> 00:07:26.920 position:50.00%,middle align:middle size:80.00% line:79.33% En las plantas cuatro y cinco, los servicios administrativos. 117 00:07:27.080 --> 00:07:28.520 position:50.00%,middle align:middle size:80.00% line:84.67% En los pisos seis y siete, 118 00:07:28.680 --> 00:07:31.120 position:50.00%,middle align:middle size:80.00% line:79.33% las centralitas, donde las elegidas tendrán 119 00:07:31.280 --> 00:07:34.240 position:50.00%,middle align:middle size:80.00% line:79.33% el honor de trabajar. A continuación tenemos dos plantas 120 00:07:34.400 --> 00:07:35.920 position:50.00%,middle align:middle size:80.00% line:84.67% para las salas de máquinas, 121 00:07:36.000 --> 00:07:38.040 position:50.00%,middle align:middle size:80.00% line:84.67% Mantenimiento y despachos de Ingeniería. 122 00:07:38.200 --> 00:07:41.960 position:50.00%,middle align:middle size:80.00% line:79.33% Más arriba estarían las plantas destinadas a eventos y reuniones. 123 00:07:42.120 --> 00:07:44.360 position:50.00%,middle align:middle size:80.00% line:79.33% Y, por último, en la planta 13 del edificio, 124 00:07:44.520 --> 00:07:46.120 position:50.00%,middle align:middle size:80.00% line:84.67% los despachos de Dirección. 125 00:08:00.040 --> 00:08:02.520 position:50.00%,middle align:middle size:80.00% line:79.33% - Gracias por todo. - Gracias, señor Cifuentes. 126 00:08:03.720 --> 00:08:04.920 position:50.00%,middle align:middle size:80.00% line:84.67% Eh, disculpe. 127 00:08:33.440 --> 00:08:34.840 position:50.00%,middle align:middle size:80.00% line:84.67% Señorita... 128 00:08:35.000 --> 00:08:37.080 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Puedo saber lo que está haciendo aquí? 129 00:08:37.679 --> 00:08:40.200 position:50.00%,middle align:middle size:80.00% line:79.33% [cándida] Vengo a las pruebas de telefonista 130 00:08:40.360 --> 00:08:41.960 position:50.00%,middle align:middle size:80.00% line:84.67% y me habían dicho que era aquí, 131 00:08:42.120 --> 00:08:44.360 position:50.00%,middle align:middle size:80.00% line:79.33% pero es que no veo a nadie por ningún sitio. 132 00:08:45.000 --> 00:08:47.480 position:50.00%,middle align:middle size:80.00% line:79.33% [risita] Sí. Son aquí si lleva traje y chaqueta, es un hombre 133 00:08:47.560 --> 00:08:49.240 position:50.00%,middle align:middle size:80.00% line:84.67% y pretende ser ejecutivo de cuentas, 134 00:08:49.400 --> 00:08:51.120 position:50.00%,middle align:middle size:80.00% line:84.67% cosa que, por lo que veo, no es el caso. 135 00:08:51.280 --> 00:08:52.920 position:50.00%,middle align:middle size:80.00% line:84.67% Las pruebas son en la sexta planta 136 00:08:53.080 --> 00:08:55.840 position:50.00%,middle align:middle size:80.00% line:79.33% y le quedan dos minutos para que le cierren las puertas, 137 00:08:56.000 --> 00:08:58.720 position:50.00%,middle align:middle size:80.00% line:79.33% y en esta empresa lo más importante es la puntualidad. 138 00:08:58.880 --> 00:08:59.920 position:50.00%,middle align:middle size:80.00% line:84.67% Muchas gracias. 139 00:09:02.360 --> 00:09:04.400 position:50.00%,middle align:middle size:80.00% line:84.67% Muy bien. ¿Ricardo Rodríguez? 140 00:09:05.680 --> 00:09:07.240 position:50.00%,middle align:middle size:80.00% line:84.67% Eh, bonita... 141 00:09:07.400 --> 00:09:08.920 position:50.00%,middle align:middle size:80.00% line:84.67% Por la escalera mejor... 142 00:09:11.240 --> 00:09:12.240 position:50.00%,middle align:middle size:80.00% line:84.67% [risita] 143 00:09:24.320 --> 00:09:25.960 position:50.00%,middle align:middle size:80.00% line:84.67% [ambas gritan] 144 00:09:28.040 --> 00:09:30.400 position:50.00%,middle align:middle size:80.00% line:84.67% Ay, que nos han cerrado, estamos fuera. 145 00:09:30.560 --> 00:09:32.360 position:50.00%,middle align:middle size:80.00% line:84.67% Las pruebas empezaban a las 12. 146 00:09:32.520 --> 00:09:33.800 position:50.00%,middle align:middle size:80.00% line:84.67% Es que soy lo peor. 147 00:09:33.960 --> 00:09:36.920 position:50.00%,middle align:middle size:80.00% line:79.33% - Me perdí tres veces en el metro. - [Ángeles] No, no, no, no puede... 148 00:09:38.360 --> 00:09:40.200 position:50.00%,middle align:middle size:80.00% line:79.33% - ¿Sí? - Disculpe. 149 00:09:40.800 --> 00:09:43.120 position:50.00%,middle align:middle size:80.00% line:79.33% - Venimos a las pruebas. - Llegan tarde. 150 00:09:43.280 --> 00:09:46.680 position:50.00%,middle align:middle size:80.00% line:79.33% Lo sé. Sé que lo primero en esta empresa es la puntualidad. 151 00:09:46.840 --> 00:09:49.160 position:50.00%,middle align:middle size:80.00% line:79.33% Entonces, ¿para qué ha llamado a la puerta? 152 00:09:50.040 --> 00:09:52.320 position:50.00%,middle align:middle size:80.00% line:84.67% Para saber si lo segundo 153 00:09:52.480 --> 00:09:53.600 position:50.00%,middle align:middle size:80.00% line:84.67% es la comprensión. 154 00:09:57.080 --> 00:09:58.440 position:50.00%,middle align:middle size:80.00% line:84.67% ¡Mírela! 155 00:09:58.880 --> 00:10:00.880 position:50.00%,middle align:middle size:80.00% line:84.67% Directamente desde la estación 156 00:10:01.040 --> 00:10:04.200 position:50.00%,middle align:middle size:80.00% line:79.33% con la misma maleta, 600 km para poder estar aquí ahora. 157 00:10:04.360 --> 00:10:05.800 position:50.00%,middle align:middle size:80.00% line:79.33% - 550. - Sí. 158 00:10:06.880 --> 00:10:10.320 position:50.00%,middle align:middle size:80.00% line:79.33% Y por solo un minuto va a volverse a casa con las manos vacías. 159 00:10:11.880 --> 00:10:12.840 position:50.00%,middle align:middle size:80.00% line:84.67% La culpa ha sido mía. 160 00:10:13.760 --> 00:10:15.160 position:50.00%,middle align:middle size:80.00% line:84.67% Señorita Millán... 161 00:10:16.960 --> 00:10:18.480 position:50.00%,middle align:middle size:80.00% line:84.67% [tartamudea] Lo siento mucho. 162 00:10:19.440 --> 00:10:21.160 position:50.00%,middle align:middle size:80.00% line:84.67% Yo me he ofrecido a acompañarlas 163 00:10:21.320 --> 00:10:23.360 position:50.00%,middle align:middle size:80.00% line:84.67% de paso que le traía la documentación 164 00:10:23.520 --> 00:10:25.960 position:50.00%,middle align:middle size:80.00% line:79.33% que me pidió usted y me he confundido de piso. 165 00:10:26.120 --> 00:10:27.320 position:50.00%,middle align:middle size:80.00% line:84.67% Se equivocó de piso... 166 00:10:27.480 --> 00:10:28.480 position:50.00%,middle align:middle size:80.00% line:84.67% Sí. 167 00:10:28.880 --> 00:10:31.640 position:50.00%,middle align:middle size:80.00% line:79.33% Su primera equivocación desde que trabaja para mí. 168 00:10:31.800 --> 00:10:34.280 position:50.00%,middle align:middle size:80.00% line:79.33% Nuestros trabajadores son sumamente comprensivos. 169 00:10:34.440 --> 00:10:37.000 position:50.00%,middle align:middle size:80.00% line:79.33% Aunque, para su desgracia, no depende de Ángeles 170 00:10:37.160 --> 00:10:39.360 position:50.00%,middle align:middle size:80.00% line:79.33% que crucen o no esta puerta, depende de mí. 171 00:10:49.040 --> 00:10:50.640 position:50.00%,middle align:middle size:80.00% line:79.33% - Gracias. - Sí, muchas gracias. 172 00:10:50.800 --> 00:10:53.040 position:50.00%,middle align:middle size:80.00% line:79.33% Si me presento en el pueblo sin haber hecho... 173 00:10:53.200 --> 00:10:54.600 position:50.00%,middle align:middle size:80.00% line:79.33% - Ángeles, ¿verdad? - Ajá. 174 00:10:54.760 --> 00:10:57.880 position:50.00%,middle align:middle size:80.00% line:79.33% Con ese nombre, solamente podías ser nuestro ángel de la guarda. 175 00:10:58.040 --> 00:10:59.640 position:50.00%,middle align:middle size:80.00% line:84.67% Yo no diría tanto... 176 00:11:01.680 --> 00:11:03.080 position:50.00%,middle align:middle size:80.00% line:84.67% Perdón. Aquí. 177 00:11:04.680 --> 00:11:05.800 position:50.00%,middle align:middle size:80.00% line:84.67% Disculpe. 178 00:11:07.000 --> 00:11:10.120 position:50.00%,middle align:middle size:80.00% line:79.33% No habría alguna posibilidad de pasar las pruebas 179 00:11:10.280 --> 00:11:12.040 position:50.00%,middle align:middle size:80.00% line:79.33% - sin hacerlas, ¿verdad? - No. 180 00:11:12.200 --> 00:11:13.240 position:50.00%,middle align:middle size:80.00% line:84.67% Buenos días. 181 00:11:13.800 --> 00:11:15.280 position:50.00%,middle align:middle size:80.00% line:84.67% Señoritas... 182 00:11:19.240 --> 00:11:22.000 position:50.00%,middle align:middle size:80.00% line:79.33% Veo que hoy va a ser un gran día para esta compañía. 183 00:11:22.480 --> 00:11:24.360 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Vienen todas a las pruebas? 184 00:11:25.320 --> 00:11:27.600 position:50.00%,middle align:middle size:80.00% line:84.67% Me parece que va a ser muy difícil elegir. 185 00:11:28.320 --> 00:11:30.240 position:50.00%,middle align:middle size:80.00% line:84.67% Ojalá pudiéramos comprar más centralitas 186 00:11:30.400 --> 00:11:31.720 position:50.00%,middle align:middle size:80.00% line:84.67% para darles trabajo a todas. 187 00:11:31.800 --> 00:11:33.240 position:50.00%,middle align:middle size:80.00% line:79.33% [Ángeles ríe] [lapicero cae] 188 00:11:39.040 --> 00:11:40.200 position:50.00%,middle align:middle size:80.00% line:84.67% Gracias. 189 00:11:40.360 --> 00:11:43.880 position:50.00%,middle align:middle size:80.00% line:79.33% No quiero ponerlas más nerviosas de lo que ya están, así que... 190 00:11:44.440 --> 00:11:47.360 position:50.00%,middle align:middle size:80.00% line:79.33% les deseo mucha suerte a cada una de ustedes. 191 00:11:48.280 --> 00:11:49.440 position:50.00%,middle align:middle size:80.00% line:84.67% [inaudible] 192 00:12:36.760 --> 00:12:39.440 position:50.00%,middle align:middle size:80.00% line:84.67% Remedios Fuentes, admitida. Lucía Billar, 193 00:12:39.600 --> 00:12:41.800 position:50.00%,middle align:middle size:80.00% line:84.67% no admitida. Marta Rodríguez, admitida. 194 00:12:41.960 --> 00:12:45.080 position:50.00%,middle align:middle size:80.00% line:79.33% Laura Sarmiento, admitida. Verónica Sánchez, admitida. 195 00:12:45.240 --> 00:12:48.960 position:50.00%,middle align:middle size:80.00% line:79.33% Ángeles Montes, no admitida. Carlota Rodríguez de Senillosa, 196 00:12:49.120 --> 00:12:51.760 position:50.00%,middle align:middle size:80.00% line:84.67% admitida. María José Pando, admitida. 197 00:12:51.920 --> 00:12:54.440 position:50.00%,middle align:middle size:80.00% line:84.67% María Inmaculada Suárez, admitida. 198 00:12:55.840 --> 00:12:58.360 position:50.00%,middle align:middle size:80.00% line:84.67% Enhorabuena a las elegidas. 199 00:12:59.680 --> 00:13:00.880 position:50.00%,middle align:middle size:80.00% line:84.67% Disculpe. 200 00:13:01.280 --> 00:13:02.320 position:50.00%,middle align:middle size:80.00% line:84.67% Perdón. 201 00:13:02.480 --> 00:13:05.840 position:50.00%,middle align:middle size:80.00% line:79.33% - No ha dicho mi nombre. - Eso es que no ha pasado las pruebas. 202 00:13:06.000 --> 00:13:07.520 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Cómo? Me gustaría ver el listado. 203 00:13:07.680 --> 00:13:11.160 position:50.00%,middle align:middle size:80.00% line:79.33% Los resultados no son públicos, pero le aseguro que sí son justos. 204 00:13:11.320 --> 00:13:13.600 position:50.00%,middle align:middle size:80.00% line:79.33% - Tengo derecho a saber... - No tiene derecho a nada. 205 00:13:13.760 --> 00:13:15.760 position:50.00%,middle align:middle size:80.00% line:84.67% No ha pasado las pruebas, punto y final. 206 00:13:15.920 --> 00:13:16.920 position:50.00%,middle align:middle size:80.00% line:84.67% [Carlos] ¿Va todo bien? 207 00:13:31.320 --> 00:13:33.840 position:50.00%,middle align:middle size:80.00% line:79.33% Señorita Aguilar, ¿en qué academia ha estudiado? 208 00:13:34.000 --> 00:13:35.040 position:50.00%,middle align:middle size:80.00% line:84.67% Soy autodidacta. 209 00:13:35.200 --> 00:13:36.360 position:50.00%,middle align:middle size:80.00% line:79.33% - ¿Autodidacta? - Sí. 210 00:13:36.520 --> 00:13:39.200 position:50.00%,middle align:middle size:80.00% line:79.33% ¿Usted nunca ha estudiado nada por su cuenta? 211 00:13:39.360 --> 00:13:42.000 position:50.00%,middle align:middle size:80.00% line:84.67% Yo, si le soy sincero, nunca he estudiado. 212 00:13:42.800 --> 00:13:44.960 position:50.00%,middle align:middle size:80.00% line:84.67% Pero no deje que se entere mi padre, 213 00:13:45.120 --> 00:13:46.440 position:50.00%,middle align:middle size:80.00% line:84.67% no me gustaría que pensase 214 00:13:46.600 --> 00:13:49.240 position:50.00%,middle align:middle size:80.00% line:79.33% que desperdició su dinero pagándome un internado. 215 00:13:52.560 --> 00:13:53.640 position:50.00%,middle align:middle size:80.00% line:84.67% Me imagino que no sabe 216 00:13:53.800 --> 00:13:56.160 position:50.00%,middle align:middle size:80.00% line:79.33% por qué, habiendo unas pruebas más que decentes, 217 00:13:56.320 --> 00:13:57.680 position:50.00%,middle align:middle size:80.00% line:84.67% no está entre las elegidas. 218 00:13:57.840 --> 00:13:58.960 position:50.00%,middle align:middle size:80.00% line:84.67% Sus brazos... 219 00:14:00.600 --> 00:14:02.800 position:50.00%,middle align:middle size:80.00% line:79.33% - ¿Cómo? - No son lo suficientemente largos. 220 00:14:02.960 --> 00:14:05.000 position:50.00%,middle align:middle size:80.00% line:84.67% Muchos abonados, paneles muy grandes... 221 00:14:05.160 --> 00:14:08.640 position:50.00%,middle align:middle size:80.00% line:79.33% A veces una telefonista tiene que hacerse cargo de hasta dos paneles. 222 00:14:08.800 --> 00:14:10.200 position:50.00%,middle align:middle size:80.00% line:84.67% Con su permiso... 223 00:14:11.960 --> 00:14:15.000 position:50.00%,middle align:middle size:80.00% line:79.33% Le faltan dos centímetros para estar en el mínimo exigido. 224 00:14:15.760 --> 00:14:17.880 position:50.00%,middle align:middle size:80.00% line:84.67% Perdone el atrevimiento, don Carlos, 225 00:14:18.040 --> 00:14:20.560 position:50.00%,middle align:middle size:80.00% line:79.33% pero este requisito me parece una tontería. 226 00:14:20.720 --> 00:14:21.880 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Perdón? 227 00:14:22.040 --> 00:14:25.680 position:50.00%,middle align:middle size:80.00% line:79.33% Trinidad Seijo, telefonista de la centralita de mi pueblo, 228 00:14:25.840 --> 00:14:28.920 position:50.00%,middle align:middle size:80.00% line:79.33% controlaba toda la comarca, más de 300 abonados, 229 00:14:29.080 --> 00:14:32.080 position:50.00%,middle align:middle size:80.00% line:79.33% todo lo hacía con una mano... porque era manca. 230 00:14:33.560 --> 00:14:36.080 position:50.00%,middle align:middle size:80.00% line:79.33% Así que no creo que sea una cuestión de centímetros 231 00:14:36.240 --> 00:14:37.240 position:50.00%,middle align:middle size:80.00% line:84.67% sino de habilidad. 232 00:14:40.040 --> 00:14:42.160 position:50.00%,middle align:middle size:80.00% line:84.67% Dígame por qué quiere ser telefonista. 233 00:14:42.320 --> 00:14:45.400 position:50.00%,middle align:middle size:80.00% line:79.33% Quiere que le diga que es el mejor trabajo del mundo, ¿verdad? 234 00:14:45.560 --> 00:14:46.960 position:50.00%,middle align:middle size:80.00% line:84.67% Pero es que no puedo hacer eso 235 00:14:47.120 --> 00:14:49.560 position:50.00%,middle align:middle size:80.00% line:79.33% porque entonces usted y yo sabríamos que miento. 236 00:14:50.720 --> 00:14:53.520 position:50.00%,middle align:middle size:80.00% line:79.33% El suyo es mucho mejor. Pero, por desgracia, 237 00:14:53.680 --> 00:14:55.320 position:50.00%,middle align:middle size:80.00% line:84.67% me han dicho que no está vacante. 238 00:14:55.480 --> 00:14:58.120 position:50.00%,middle align:middle size:80.00% line:79.33% Con carácter, don de la palabra y sentido del humor. 239 00:14:58.280 --> 00:15:00.880 position:50.00%,middle align:middle size:80.00% line:79.33% Estoy segura de que no es la primera que conoce así. 240 00:15:01.040 --> 00:15:02.520 position:50.00%,middle align:middle size:80.00% line:84.67% He conocido unas pocas, sí. 241 00:15:03.440 --> 00:15:05.200 position:50.00%,middle align:middle size:80.00% line:84.67% Aunque no sé si alguna como usted. 242 00:15:05.840 --> 00:15:07.240 position:50.00%,middle align:middle size:80.00% line:84.67% No, como yo no. 243 00:15:11.000 --> 00:15:13.400 position:50.00%,middle align:middle size:80.00% line:84.67% Carolina, acompañe a la señorita abajo... 244 00:15:14.440 --> 00:15:17.120 position:50.00%,middle align:middle size:80.00% line:79.33% y confírmele a Sara que está entre las seleccionadas. 245 00:15:17.280 --> 00:15:19.000 position:50.00%,middle align:middle size:80.00% line:79.33% - Bienvenida, señorita Aguilar. - Gracias. 246 00:15:19.160 --> 00:15:21.560 position:50.00%,middle align:middle size:80.00% line:79.33% La próxima vez que quiera impresionar a un directivo 247 00:15:21.720 --> 00:15:23.600 position:50.00%,middle align:middle size:80.00% line:79.33% no le diga que sueña con quitarle el puesto. 248 00:15:23.760 --> 00:15:26.480 position:50.00%,middle align:middle size:80.00% line:79.33% Y usted la próxima vez que quiera impresionar a una mujer 249 00:15:26.640 --> 00:15:29.200 position:50.00%,middle align:middle size:80.00% line:79.33% no le haga sentir que solo le interesan sus medidas. 250 00:15:32.920 --> 00:15:35.800 position:50.00%,middle align:middle size:80.00% line:79.33% Al final te ha servido esa carita de no haber roto un plato. 251 00:15:36.400 --> 00:15:37.720 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Perdona? 252 00:15:37.880 --> 00:15:41.120 position:50.00%,middle align:middle size:80.00% line:79.33% - No sé de qué me estás hablando... - La actitud de mosquita muerta. 253 00:15:41.280 --> 00:15:44.040 position:50.00%,middle align:middle size:80.00% line:79.33% Te servirá con Sara y con don Carlos, pero conmigo no. 254 00:15:44.200 --> 00:15:45.600 position:50.00%,middle align:middle size:80.00% line:84.67% Te voy a estar vigilando. 255 00:15:49.680 --> 00:15:52.320 position:50.00%,middle align:middle size:80.00% line:84.67% Bonita, por la escalera mejor... 256 00:15:58.560 --> 00:15:59.720 position:50.00%,middle align:middle size:80.00% line:84.67% [ríe suavemente] 257 00:16:03.880 --> 00:16:05.960 position:50.00%,middle align:middle size:80.00% line:84.67% [señal de teléfono y voces] 258 00:16:06.120 --> 00:16:09.080 position:50.00%,middle align:middle size:80.00% line:79.33% [Ángeles] Buenas tardes, operadora tres, ¿en qué puedo ayudarle? 259 00:16:10.880 --> 00:16:12.320 position:50.00%,middle align:middle size:80.00% line:84.67% Un momento, por favor. 260 00:16:12.480 --> 00:16:14.600 position:50.00%,middle align:middle size:80.00% line:84.67% Buenos días, operadora número 20. 261 00:16:20.000 --> 00:16:23.960 position:50.00%,middle align:middle size:80.00% line:79.33% Aquí Central. Tiene una llamada del abonado 235. 262 00:16:24.160 --> 00:16:25.520 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Desea aceptarla? 263 00:16:26.280 --> 00:16:27.520 position:50.00%,middle align:middle size:80.00% line:84.67% Gracias. 264 00:16:28.000 --> 00:16:30.200 position:50.00%,middle align:middle size:80.00% line:84.67% [Sara] Gracias, Ángeles, es suficiente. 265 00:16:30.360 --> 00:16:33.240 position:50.00%,middle align:middle size:80.00% line:79.33% Ángeles es la que mejor conoce esta empresa y su trabajo. 266 00:16:33.400 --> 00:16:35.440 position:50.00%,middle align:middle size:80.00% line:84.67% [Alba] La telefonista que mejor conocía 267 00:16:35.600 --> 00:16:38.120 position:50.00%,middle align:middle size:80.00% line:79.33% esa empresa y su trabajo. Sin duda, si alguien 268 00:16:38.280 --> 00:16:40.200 position:50.00%,middle align:middle size:80.00% line:84.67% podía contarme todo lo que necesitaba 269 00:16:40.360 --> 00:16:43.600 position:50.00%,middle align:middle size:80.00% line:79.33% para robar aquella caja fuerte y cumplir mi sueño era Ángeles. 270 00:16:43.760 --> 00:16:45.280 position:50.00%,middle align:middle size:80.00% line:84.67% ...mañana, tarde y noche. 271 00:16:45.440 --> 00:16:48.280 position:50.00%,middle align:middle size:80.00% line:79.33% Solo tenía que acercarme a ella y conseguir que confiase 272 00:16:48.440 --> 00:16:51.000 position:50.00%,middle align:middle size:80.00% line:79.33% lo suficiente en mí como para contarme los secretos 273 00:16:51.160 --> 00:16:54.920 position:50.00%,middle align:middle size:80.00% line:79.33% - de la Compañía de Telefonía. - Cuando suena el gong, es el momento 274 00:16:55.080 --> 00:16:57.360 position:50.00%,middle align:middle size:80.00% line:79.33% del cambio de puesto, ¿está todo claro? Bien. 275 00:16:57.520 --> 00:16:58.800 position:50.00%,middle align:middle size:80.00% line:84.67% Ahora, si me disculpan, 276 00:16:58.960 --> 00:17:01.560 position:50.00%,middle align:middle size:80.00% line:79.33% el director de la compañía quiere presentarse. 277 00:17:02.440 --> 00:17:05.680 position:50.00%,middle align:middle size:80.00% line:79.33% Don Francisco, le presento a las nuevas incorporaciones: 278 00:17:05.839 --> 00:17:06.880 position:50.00%,middle align:middle size:80.00% line:84.67% Marta Rodríguez... 279 00:17:07.040 --> 00:17:10.400 position:50.00%,middle align:middle size:80.00% line:79.33% El dire... Pero ¿el director no era don Carlos Cifuentes? 280 00:17:10.560 --> 00:17:13.480 position:50.00%,middle align:middle size:80.00% line:79.33% - Es el hijo del dueño. - ¿Y quién es el director? 281 00:17:14.480 --> 00:17:15.960 position:50.00%,middle align:middle size:80.00% line:84.67% [Sara] Laura Sarmiento. 282 00:17:17.319 --> 00:17:18.720 position:50.00%,middle align:middle size:80.00% line:84.67% Petra Silva. 283 00:17:19.480 --> 00:17:20.720 position:50.00%,middle align:middle size:80.00% line:84.67% Lourdes Trapero. 284 00:17:22.640 --> 00:17:24.400 position:50.00%,middle align:middle size:80.00% line:84.67% Ella es la señorita Raquel Levante. 285 00:17:24.960 --> 00:17:28.760 position:50.00%,middle align:middle size:80.00% line:79.33% Remedios Fuentes. Carlota Rodríguez de Senillosa. 286 00:17:29.720 --> 00:17:31.200 position:50.00%,middle align:middle size:80.00% line:84.67% Lidia Aguilar. 287 00:17:32.080 --> 00:17:33.240 position:50.00%,middle align:middle size:80.00% line:84.67% Alba. 288 00:17:34.920 --> 00:17:37.600 position:50.00%,middle align:middle size:80.00% line:79.33% Lo lamento, pero se ha equivocado de persona, no... 289 00:17:38.160 --> 00:17:39.960 position:50.00%,middle align:middle size:80.00% line:84.67% Me llamo Lidia Aguilar. 290 00:17:40.120 --> 00:17:41.160 position:50.00%,middle align:middle size:80.00% line:84.67% Encantada. 291 00:17:43.360 --> 00:17:45.000 position:50.00%,middle align:middle size:80.00% line:84.67% Lo siento, señorita Aguilar. 292 00:17:45.520 --> 00:17:47.400 position:50.00%,middle align:middle size:80.00% line:84.67% Me ha recordado a una vieja amiga. 293 00:17:48.520 --> 00:17:49.960 position:50.00%,middle align:middle size:80.00% line:84.67% No se apure. 294 00:17:53.320 --> 00:17:54.600 position:50.00%,middle align:middle size:80.00% line:84.67% [Sara] María José Pando. 295 00:17:57.560 --> 00:17:59.960 position:50.00%,middle align:middle size:80.00% line:79.33% [joven Francisco] Corre, Alba, vamos, venga. 296 00:18:00.120 --> 00:18:01.240 position:50.00%,middle align:middle size:80.00% line:84.67% ¡Corre, corre, corre! 297 00:18:01.400 --> 00:18:03.320 position:50.00%,middle align:middle size:80.00% line:10.00% Pasa, pásame la maleta. 298 00:18:03.480 --> 00:18:06.160 position:50.00%,middle align:middle size:80.00% line:10.00% [joven Alba] Aún no me creo que nos vayamos a Madrid. 299 00:18:06.320 --> 00:18:07.800 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Te estás arrepintiendo? 300 00:18:07.960 --> 00:18:09.000 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Tú estás loco? 301 00:18:09.440 --> 00:18:12.400 position:50.00%,middle align:middle size:80.00% line:79.33% Estaba pensando en todas las cosas que haré cuando llegue, 302 00:18:12.560 --> 00:18:13.960 position:50.00%,middle align:middle size:80.00% line:84.67% como encontrar un trabajo. 303 00:18:14.120 --> 00:18:17.880 position:50.00%,middle align:middle size:80.00% line:79.33% Después ahorraré mucho, muchísimo y me compraré un vestido precioso. 304 00:18:18.040 --> 00:18:20.440 position:50.00%,middle align:middle size:80.00% line:79.33% E iré a la verbena y me tomaré un white lady. 305 00:18:20.600 --> 00:18:21.760 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Un white lady? 306 00:18:21.920 --> 00:18:24.360 position:50.00%,middle align:middle size:80.00% line:79.33% Claro. Es lo que toman las chicas de ciudad. 307 00:18:25.880 --> 00:18:27.640 position:50.00%,middle align:middle size:80.00% line:79.33% - ¿Qué vas a hacer tú? - ¿Yo? 308 00:18:28.280 --> 00:18:31.320 position:50.00%,middle align:middle size:80.00% line:79.33% Voy a besar este lunar tan bonito que tienes en el cuello. 309 00:18:44.200 --> 00:18:46.560 position:50.00%,middle align:middle size:80.00% line:84.67% ¡Eh! ¡Mi maleta! ¡Oiga! 310 00:18:47.080 --> 00:18:48.240 position:50.00%,middle align:middle size:80.00% line:84.67% ¡Oiga! 311 00:18:54.960 --> 00:18:58.480 position:50.00%,middle align:middle size:80.00% line:79.33% - Eh, eh, ladrón, que es mi maleta. - Oiga, no, es mi maleta. Oiga. 312 00:19:00.120 --> 00:19:02.320 position:50.00%,middle align:middle size:80.00% line:84.67% ¡Suélteme! ¡Francisco! 313 00:19:02.480 --> 00:19:04.240 position:50.00%,middle align:middle size:80.00% line:84.67% ¡Francisco! 314 00:19:12.280 --> 00:19:13.920 position:50.00%,middle align:middle size:80.00% line:84.67% [teléfono] 315 00:19:16.040 --> 00:19:18.240 position:50.00%,middle align:middle size:80.00% line:84.67% [teléfono] 316 00:19:20.360 --> 00:19:23.480 position:50.00%,middle align:middle size:80.00% line:79.33% - Te dije que no me llamaras aquí. - [Alba] Es una emergencia, Beltrán. 317 00:19:23.640 --> 00:19:24.880 position:50.00%,middle align:middle size:80.00% line:84.67% Ha surgido un problema. 318 00:19:25.040 --> 00:19:27.320 position:50.00%,middle align:middle size:80.00% line:79.33% Y no puedo dar el golpe que tenía previsto, 319 00:19:27.480 --> 00:19:29.920 position:50.00%,middle align:middle size:80.00% line:79.33% necesito buscar otro sitio... y más tiempo. 320 00:19:30.080 --> 00:19:34.400 position:50.00%,middle align:middle size:80.00% line:79.33% No me cuentes tu vida. ¿Sabes cuál es la condena por asesinato? 321 00:19:34.560 --> 00:19:35.640 position:50.00%,middle align:middle size:80.00% line:84.67% La muerte. 322 00:19:35.800 --> 00:19:37.960 position:50.00%,middle align:middle size:80.00% line:84.67% Tienes un día para librarte de ella. 323 00:19:38.120 --> 00:19:39.960 position:50.00%,middle align:middle size:80.00% line:84.67% Mañana a la una en el parque. 324 00:19:40.120 --> 00:19:41.600 position:50.00%,middle align:middle size:80.00% line:84.67% [Beltrán cuelga] 325 00:19:42.320 --> 00:19:45.560 position:50.00%,middle align:middle size:80.00% line:79.33% Carlota... ¿Entonces sabes cómo llegar aquí? 326 00:19:46.240 --> 00:19:48.920 position:50.00%,middle align:middle size:80.00% line:84.67% Pensión Dolores, calle Blancaró. 327 00:19:49.080 --> 00:19:52.440 position:50.00%,middle align:middle size:80.00% line:79.33% Sí, mujer, esto no tiene pérdida. Mira, tienes que seguir todo recto 328 00:19:52.600 --> 00:19:54.600 position:50.00%,middle align:middle size:80.00% line:84.67% y luego giras la tercera a la izquierda. 329 00:19:54.760 --> 00:19:57.640 position:50.00%,middle align:middle size:80.00% line:79.33% - [hombre] Es la segunda a la izquierda. - ¿Y quién dice eso? 330 00:19:57.800 --> 00:19:59.200 position:50.00%,middle align:middle size:80.00% line:84.67% Miguel Pascual. 331 00:20:00.040 --> 00:20:01.720 position:50.00%,middle align:middle size:80.00% line:84.67% Ingeniero de comunicaciones. 332 00:20:01.880 --> 00:20:05.080 position:50.00%,middle align:middle size:80.00% line:79.33% Madrileño de cuna, un humilde conocedor de la villa. 333 00:20:05.320 --> 00:20:07.520 position:50.00%,middle align:middle size:80.00% line:79.33% [ríe] ¿Has oído, Marga? Un hombre polifacético. 334 00:20:07.680 --> 00:20:09.760 position:50.00%,middle align:middle size:80.00% line:84.67% Míralo, porque de esos quedan muy pocos. 335 00:20:09.920 --> 00:20:11.080 position:50.00%,middle align:middle size:80.00% line:84.67% Le digo lo mismo... 336 00:20:11.240 --> 00:20:12.320 position:50.00%,middle align:middle size:80.00% line:84.67% ¡Ah! 337 00:20:13.200 --> 00:20:14.440 position:50.00%,middle align:middle size:80.00% line:84.67% Él es Miguel. 338 00:20:14.600 --> 00:20:16.720 position:50.00%,middle align:middle size:80.00% line:84.67% [azorada] Ah, pero que es tu novio... 339 00:20:16.880 --> 00:20:20.040 position:50.00%,middle align:middle size:80.00% line:79.33% No repitas esa palabra delante de ella, es alérgica. 340 00:20:20.200 --> 00:20:23.400 position:50.00%,middle align:middle size:80.00% line:79.33% Oye, luego voy a tomarme algo al bar de enfrente, ¿te apuntas? 341 00:20:23.560 --> 00:20:24.920 position:50.00%,middle align:middle size:80.00% line:84.67% Cierro unas cosas y te busco. 342 00:20:26.480 --> 00:20:30.360 position:50.00%,middle align:middle size:80.00% line:79.33% Lidia, ¿te vienes al bar de enfrente a tomar algo con nosotras? 343 00:20:30.520 --> 00:20:32.280 position:50.00%,middle align:middle size:80.00% line:84.67% No, tengo cosas que hacer. 344 00:20:32.440 --> 00:20:33.480 position:50.00%,middle align:middle size:80.00% line:84.67% [Marga] ¿Yo puedo ir? 345 00:20:33.640 --> 00:20:36.760 position:50.00%,middle align:middle size:80.00% line:79.33% Aunque sea a agradecerle a Ángeles todo lo que ha hecho por nosotras. 346 00:20:36.920 --> 00:20:38.880 position:50.00%,middle align:middle size:80.00% line:79.33% - ¿Ángeles va? - Sí. 347 00:20:39.040 --> 00:20:41.840 position:50.00%,middle align:middle size:80.00% line:79.33% Va a dejar al niño con la vecina porque no quería demorarse mucho. 348 00:20:42.000 --> 00:20:43.720 position:50.00%,middle align:middle size:80.00% line:79.33% - Bueno, ¿qué, te vienes? - [Ángeles] Hola. 349 00:20:45.320 --> 00:20:47.880 position:50.00%,middle align:middle size:80.00% line:79.33% La familia Cifuentes ha hecho una inversión muy grande 350 00:20:48.040 --> 00:20:49.600 position:50.00%,middle align:middle size:80.00% line:84.67% con la construcción del edificio. 351 00:20:49.760 --> 00:20:52.080 position:50.00%,middle align:middle size:80.00% line:79.33% Ahora es lógico que quieran aumentar la seguridad. 352 00:20:52.240 --> 00:20:54.440 position:50.00%,middle align:middle size:80.00% line:79.33% Y solo se puede acceder durante nuestro turno... 353 00:20:54.600 --> 00:20:56.200 position:50.00%,middle align:middle size:80.00% line:84.67% Claro, tienen que ser precavidos. 354 00:20:56.360 --> 00:20:59.840 position:50.00%,middle align:middle size:80.00% line:79.33% Todo pasa por la central: recaudación de la empresa, el sueldo 355 00:21:00.000 --> 00:21:01.720 position:50.00%,middle align:middle size:80.00% line:84.67% de los empleados... Básicamente todo. 356 00:21:01.880 --> 00:21:03.760 position:50.00%,middle align:middle size:80.00% line:84.67% [Marga] Abuela, que me va a dejar sorda. 357 00:21:03.920 --> 00:21:06.400 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Ves? Si es que vales más que las pesetas. 358 00:21:06.560 --> 00:21:08.320 position:50.00%,middle align:middle size:80.00% line:84.67% Verás cuando se lo diga a la Rosario. 359 00:21:08.480 --> 00:21:11.040 position:50.00%,middle align:middle size:80.00% line:79.33% No, abuela, antes de que le diga nada a nadie... 360 00:21:11.840 --> 00:21:15.040 position:50.00%,middle align:middle size:80.00% line:79.33% Que, a ver, abuela, que yo lo he estado pensando y... 361 00:21:15.840 --> 00:21:17.360 position:50.00%,middle align:middle size:80.00% line:84.67% Y lo mejor es que renuncie. 362 00:21:17.520 --> 00:21:21.160 position:50.00%,middle align:middle size:80.00% line:79.33% - Que me vuelva al pueblo con usted. - ¿Cómo? Pero ¿estás loca? 363 00:21:21.720 --> 00:21:23.320 position:50.00%,middle align:middle size:80.00% line:84.67% No somos una familia de posibles 364 00:21:23.480 --> 00:21:25.760 position:50.00%,middle align:middle size:80.00% line:79.33% ni tienes un marido que te resuelva la vida. 365 00:21:25.920 --> 00:21:28.520 position:50.00%,middle align:middle size:80.00% line:79.33% No voy a dejar que te quedes el resto de tu vida en el pueblo 366 00:21:28.680 --> 00:21:29.960 position:50.00%,middle align:middle size:80.00% line:84.67% cuidando de una vieja, no. 367 00:21:30.120 --> 00:21:31.720 position:50.00%,middle align:middle size:80.00% line:84.67% Ya te has sacrificado suficiente 368 00:21:31.880 --> 00:21:34.360 position:50.00%,middle align:middle size:80.00% line:79.33% por tu madre, que en paz descanse, todos estos años. 369 00:21:34.520 --> 00:21:35.880 position:50.00%,middle align:middle size:80.00% line:84.67% Ya, abuela, pero... 370 00:21:36.280 --> 00:21:39.680 position:50.00%,middle align:middle size:80.00% line:79.33% Pero es que yo he sido muy feliz con usted y con madre. 371 00:21:39.840 --> 00:21:43.440 position:50.00%,middle align:middle size:80.00% line:79.33% Y ella contigo, hija, pero ahora tienes que ocuparte de ti misma. 372 00:21:43.600 --> 00:21:46.560 position:50.00%,middle align:middle size:80.00% line:79.33% Y por una vez en tu vida pensar solo en ti, no en los demás. 373 00:21:46.720 --> 00:21:49.560 position:50.00%,middle align:middle size:80.00% line:79.33% Ya, abuela, ya lo sé, y es lo que hago, pero... 374 00:21:51.240 --> 00:21:53.520 position:50.00%,middle align:middle size:80.00% line:79.33% Pero es que la capital me queda muy grande. 375 00:21:53.680 --> 00:21:55.360 position:50.00%,middle align:middle size:80.00% line:84.67% El miedo se pasa, Marga. 376 00:21:56.920 --> 00:21:59.440 position:50.00%,middle align:middle size:80.00% line:84.67% Y la vida también, si no se aprovecha. 377 00:21:59.600 --> 00:22:00.760 position:50.00%,middle align:middle size:80.00% line:84.67% No lo olvides. 378 00:22:00.920 --> 00:22:01.920 position:50.00%,middle align:middle size:80.00% line:84.67% Gracias. 379 00:22:02.760 --> 00:22:06.000 position:50.00%,middle align:middle size:80.00% line:79.33% Abuela, que... Que la tengo que dejar, ¿vale? 380 00:22:06.440 --> 00:22:08.840 position:50.00%,middle align:middle size:80.00% line:84.67% Cuídese mucho. [beso] La quiero. 381 00:22:14.880 --> 00:22:16.000 position:50.00%,middle align:middle size:80.00% line:84.67% Es mucho dinero... 382 00:22:16.160 --> 00:22:18.880 position:50.00%,middle align:middle size:80.00% line:79.33% [Carlota] Bueno, vamos a dejar de hablar de trabajo y vamos a brindar. 383 00:22:19.040 --> 00:22:20.120 position:50.00%,middle align:middle size:80.00% line:84.67% Los vermús. 384 00:22:20.280 --> 00:22:22.120 position:50.00%,middle align:middle size:80.00% line:84.67% Pero el vermú lleva alcohol. 385 00:22:23.080 --> 00:22:26.120 position:50.00%,middle align:middle size:80.00% line:79.33% Sí, sí, sí, pero puedes tomar otra cosa, si prefieres. 386 00:22:27.680 --> 00:22:29.400 position:50.00%,middle align:middle size:80.00% line:84.67% No, no, esto está bien. 387 00:22:30.120 --> 00:22:32.520 position:50.00%,middle align:middle size:80.00% line:79.33% Bueno, a lo mejor para mí es muy fuerte. 388 00:22:32.680 --> 00:22:35.320 position:50.00%,middle align:middle size:80.00% line:79.33% Yo hace que no me tomo uno... Ni me acuerdo. 389 00:22:35.480 --> 00:22:37.600 position:50.00%,middle align:middle size:80.00% line:79.33% No entraba a un bar desde que me quedé embarazada. 390 00:22:37.760 --> 00:22:39.200 position:50.00%,middle align:middle size:80.00% line:84.67% Así te preparas para el champán 391 00:22:39.360 --> 00:22:41.160 position:50.00%,middle align:middle size:80.00% line:79.33% - de la fiesta de esta noche. - [Marga] ¿Qué fiesta? 392 00:22:41.320 --> 00:22:43.280 position:50.00%,middle align:middle size:80.00% line:79.33% [Carlota] ¿Cómo que qué fiesta? La de inauguración. 393 00:22:43.440 --> 00:22:44.400 position:50.00%,middle align:middle size:80.00% line:84.67% [Ángeles] Va todo el mundo. 394 00:22:44.560 --> 00:22:46.880 position:50.00%,middle align:middle size:80.00% line:79.33% Yo no puedo porque me tengo que quedar con mi hija, 395 00:22:47.040 --> 00:22:49.880 position:50.00%,middle align:middle size:80.00% line:79.33% pero van telefonistas, oficinistas, van hasta los jefes. 396 00:22:50.040 --> 00:22:52.560 position:50.00%,middle align:middle size:80.00% line:79.33% Y los Cifuentes se van a mezclar con la plebe, claro. 397 00:22:52.720 --> 00:22:53.800 position:50.00%,middle align:middle size:80.00% line:84.67% Ya les toca, ¿no? 398 00:22:53.880 --> 00:22:57.480 position:50.00%,middle align:middle size:80.00% line:79.33% Yo no creo que pueda ir. Es que a mí no me gustan mucho las fiestas. 399 00:22:58.040 --> 00:23:00.400 position:50.00%,middle align:middle size:80.00% line:79.33% Marga, para decir que no te gustan las fiestas 400 00:23:00.560 --> 00:23:03.080 position:50.00%,middle align:middle size:80.00% line:79.33% tienes que haber ido al menos a alguna en tu vida, 401 00:23:03.240 --> 00:23:04.280 position:50.00%,middle align:middle size:80.00% line:84.67% y tú tienes pinta 402 00:23:04.440 --> 00:23:06.160 position:50.00%,middle align:middle size:80.00% line:84.67% de no haber ido a ninguna. 403 00:23:06.320 --> 00:23:07.800 position:50.00%,middle align:middle size:80.00% line:79.33% - Venga, vamos a brindar. - [Ángeles] Sí. 404 00:23:07.960 --> 00:23:09.120 position:50.00%,middle align:middle size:80.00% line:84.67% Vamos. Vamos, Lidia. 405 00:23:09.280 --> 00:23:11.720 position:50.00%,middle align:middle size:80.00% line:79.33% - [Ángeles] Ay, sí, vamos. - [Carlota] Vamos a brindar por Marga 406 00:23:11.880 --> 00:23:13.520 position:50.00%,middle align:middle size:80.00% line:84.67% y su primer vermú, por Ángeles, 407 00:23:13.680 --> 00:23:15.280 position:50.00%,middle align:middle size:80.00% line:79.33% - que gracias a tu ayuda... - No, no, no. 408 00:23:15.440 --> 00:23:17.080 position:50.00%,middle align:middle size:80.00% line:84.67% ...hemos tenido esta oportunidad. 409 00:23:17.240 --> 00:23:18.960 position:50.00%,middle align:middle size:80.00% line:84.67% Y por las nuevas chicas del cable. 410 00:23:19.120 --> 00:23:20.080 position:50.00%,middle align:middle size:80.00% line:79.33% - [Ángeles] ¡Sí! - Chinchín. 411 00:23:22.800 --> 00:23:26.120 position:50.00%,middle align:middle size:80.00% line:79.33% Ah, y por que a partir de ahora vamos a ser mujeres independientes. 412 00:23:26.640 --> 00:23:27.840 position:50.00%,middle align:middle size:80.00% line:84.67% [hombre] Carlota... 413 00:23:31.720 --> 00:23:32.880 position:50.00%,middle align:middle size:80.00% line:84.67% Coge tus cosas 414 00:23:33.040 --> 00:23:35.200 position:50.00%,middle align:middle size:80.00% line:79.33% - y ven conmigo ahora mismo. - No. 415 00:23:35.720 --> 00:23:37.320 position:50.00%,middle align:middle size:80.00% line:84.67% No tengo que ir a ningún sitio. 416 00:23:37.480 --> 00:23:39.840 position:50.00%,middle align:middle size:80.00% line:79.33% Tengo trabajo y soy una mujer independiente. 417 00:23:42.640 --> 00:23:45.480 position:50.00%,middle align:middle size:80.00% line:79.33% Que me suelte, que no soy ninguno de sus soldados. Suélteme. 418 00:23:45.880 --> 00:23:47.600 position:50.00%,middle align:middle size:80.00% line:79.33% - [Miguel] Don Emilio. - Y tú no te metas. 419 00:23:47.760 --> 00:23:49.400 position:50.00%,middle align:middle size:80.00% line:84.67% No eres nadie en esta familia. 420 00:23:49.600 --> 00:23:53.000 position:50.00%,middle align:middle size:80.00% line:79.33% - Me va a tener que meter a la fuerza. - ¿Pensaste que no iba a enterarme? 421 00:23:53.160 --> 00:23:55.880 position:50.00%,middle align:middle size:80.00% line:79.33% ¿Se puede saber cuándo te has convertido en una mentirosa? 422 00:23:56.040 --> 00:23:58.360 position:50.00%,middle align:middle size:80.00% line:79.33% [Carlota] Desde el momento que le da igual lo que yo quiera. 423 00:23:58.520 --> 00:24:01.200 position:50.00%,middle align:middle size:80.00% line:79.33% No quiero un marido, quiero un trabajo y ser independiente. 424 00:24:01.360 --> 00:24:03.680 position:50.00%,middle align:middle size:80.00% line:84.67% Una señorita de tu categoría no trabaja. 425 00:24:03.840 --> 00:24:06.120 position:50.00%,middle align:middle size:80.00% line:84.67% Deja de decir tonterías y sube al coche. 426 00:24:07.400 --> 00:24:08.760 position:50.00%,middle align:middle size:80.00% line:84.67% ¡He dicho que entres! 427 00:24:13.200 --> 00:24:14.800 position:50.00%,middle align:middle size:80.00% line:84.67% A sus órdenes, mi coronel. 428 00:24:21.160 --> 00:24:24.560 position:50.00%,middle align:middle size:80.00% line:79.33% - Así que ese es el... - Padre de Carlota, sí. 429 00:24:27.440 --> 00:24:31.040 position:50.00%,middle align:middle size:80.00% line:79.33% Bueno, yo mejor me voy a casa, que tengo a mi marido esperándome. 430 00:24:31.200 --> 00:24:33.280 position:50.00%,middle align:middle size:80.00% line:79.33% - ¿Sabes cómo llegar al hostal? - Sí. 431 00:24:33.440 --> 00:24:36.640 position:50.00%,middle align:middle size:80.00% line:79.33% Carlota me contó cómo llegar desde aquí hasta la calle Blancaró. 432 00:24:36.800 --> 00:24:39.080 position:50.00%,middle align:middle size:80.00% line:79.33% - Muy bien. Bueno, hasta mañana. - Hasta mañana. 433 00:24:39.240 --> 00:24:41.600 position:50.00%,middle align:middle size:80.00% line:84.67% ¡Ángeles! Espera, que te acompaño. 434 00:24:46.880 --> 00:24:49.600 position:50.00%,middle align:middle size:80.00% line:79.33% Que corra el champán. Y lo que necesiten los señores. 435 00:24:49.760 --> 00:24:52.520 position:50.00%,middle align:middle size:80.00% line:79.33% Supongo que estás aquí porque lo has conseguido, ¿no? 436 00:24:52.680 --> 00:24:55.800 position:50.00%,middle align:middle size:80.00% line:79.33% Tengo el trabajo y sé dónde está la caja... 437 00:24:55.960 --> 00:24:58.560 position:50.00%,middle align:middle size:80.00% line:79.33% Pero... me ha surgido un problema, he visto a Francisco. 438 00:24:58.720 --> 00:25:00.360 position:50.00%,middle align:middle size:80.00% line:79.33% - [Victoria] ¿Qué Francisco? - Mi Francisco. 439 00:25:00.520 --> 00:25:02.400 position:50.00%,middle align:middle size:80.00% line:79.33% - [Victoria] ¿Qué? - Es el director de la compañía. 440 00:25:02.560 --> 00:25:04.120 position:50.00%,middle align:middle size:80.00% line:79.33% - [Victoria] Pero ¿él te ha visto? - Sí. 441 00:25:04.280 --> 00:25:06.840 position:50.00%,middle align:middle size:80.00% line:79.33% [Victoria] Tienes que salir de ahí inmediatamente. 442 00:25:07.000 --> 00:25:09.920 position:50.00%,middle align:middle size:80.00% line:79.33% [Alba] No puedo. Si no pago lo que debo, me condenarán. 443 00:25:12.200 --> 00:25:16.440 position:50.00%,middle align:middle size:80.00% line:79.33% [Victoria] Tú ya sabes que si yo tuviera esa cantidad. Tú ya me conoces. 444 00:25:17.360 --> 00:25:19.200 position:50.00%,middle align:middle size:80.00% line:84.67% Victoria, voy a hacerlo esta noche. 445 00:25:19.760 --> 00:25:22.200 position:50.00%,middle align:middle size:80.00% line:79.33% Una de las chicas me ha dicho que hay seguridad, 446 00:25:22.360 --> 00:25:23.920 position:50.00%,middle align:middle size:80.00% line:84.67% pero podré, podré hacerlo. 447 00:25:24.360 --> 00:25:27.280 position:50.00%,middle align:middle size:80.00% line:79.33% Tengo que ir a la fiesta, coger las llaves, llevarme el dinero 448 00:25:27.440 --> 00:25:29.680 position:50.00%,middle align:middle size:80.00% line:79.33% y mañana por la mañana estaré muy lejos de aquí. 449 00:25:30.200 --> 00:25:31.760 position:50.00%,middle align:middle size:80.00% line:84.67% Y de Francisco también... 450 00:25:32.920 --> 00:25:34.680 position:50.00%,middle align:middle size:80.00% line:84.67% Alba, yo creo que... 451 00:25:34.880 --> 00:25:37.040 position:50.00%,middle align:middle size:80.00% line:79.33% Lo único que tengo que hacer es ser rápida. 452 00:25:38.000 --> 00:25:40.120 position:50.00%,middle align:middle size:80.00% line:84.67% Y si me lo encuentro, lo esquivo. 453 00:25:40.720 --> 00:25:43.960 position:50.00%,middle align:middle size:80.00% line:79.33% Alba, las prisas nunca han sido buenas consejeras. 454 00:25:44.320 --> 00:25:46.040 position:50.00%,middle align:middle size:80.00% line:84.67% Es una locura. 455 00:25:46.960 --> 00:25:49.000 position:50.00%,middle align:middle size:80.00% line:84.67% Victoria, es eso o el garrote. 456 00:25:51.160 --> 00:25:52.240 position:50.00%,middle align:middle size:80.00% line:84.67% Ah. 457 00:25:54.640 --> 00:25:55.800 position:50.00%,middle align:middle size:80.00% line:84.67% La 27. 458 00:25:56.280 --> 00:25:57.320 position:50.00%,middle align:middle size:80.00% line:84.67% Ven aquí. 459 00:25:59.240 --> 00:26:03.680 position:50.00%,middle align:middle size:80.00% line:79.33% Alba, cariño, ya perdí a Gimena, no quisiera perderte a ti también. 460 00:26:24.600 --> 00:26:26.840 position:50.00%,middle align:middle size:80.00% line:84.67% La cena se sirve a las ocho y media 461 00:26:27.000 --> 00:26:28.520 position:50.00%,middle align:middle size:80.00% line:84.67% y la puerta se cierra a las once. 462 00:26:28.680 --> 00:26:31.200 position:50.00%,middle align:middle size:80.00% line:79.33% Sé que no tengo que preocuparme por tu formalidad. 463 00:26:31.360 --> 00:26:33.720 position:50.00%,middle align:middle size:80.00% line:79.33% Tu abuela me ha asegurado que eres responsable. 464 00:26:33.880 --> 00:26:37.000 position:50.00%,middle align:middle size:80.00% line:79.33% Esta es una pensión decente, y ahora tu casa. 465 00:26:37.160 --> 00:26:38.160 position:50.00%,middle align:middle size:80.00% line:79.33% - Ya. - Ven. 466 00:26:42.240 --> 00:26:46.560 position:50.00%,middle align:middle size:80.00% line:79.33% Mira, sobre la cama tienes un juego de sábanas y uno de toallas, 467 00:26:46.720 --> 00:26:49.760 position:50.00%,middle align:middle size:80.00% line:79.33% para que las uses hasta que compres las tuyas. 468 00:26:49.920 --> 00:26:50.920 position:50.00%,middle align:middle size:80.00% line:84.67% Buenas noches. 469 00:26:51.080 --> 00:26:53.000 position:50.00%,middle align:middle size:80.00% line:84.67% Doña Lola, verá, es que... 470 00:26:53.480 --> 00:26:55.480 position:50.00%,middle align:middle size:80.00% line:79.33% esta noche es la inauguración de la empresa 471 00:26:55.640 --> 00:26:57.640 position:50.00%,middle align:middle size:80.00% line:84.67% y han invitado a las nuevas telefonistas. 472 00:26:57.840 --> 00:27:00.000 position:50.00%,middle align:middle size:80.00% line:79.33% Le prometo que llego antes de que cierre la puerta. 473 00:27:00.160 --> 00:27:01.480 position:50.00%,middle align:middle size:80.00% line:84.67% Ah, bien. Ten cuidado. 474 00:27:01.640 --> 00:27:03.440 position:50.00%,middle align:middle size:80.00% line:84.67% Siempre hay un joven desalmado 475 00:27:03.600 --> 00:27:06.880 position:50.00%,middle align:middle size:80.00% line:79.33% esperando para aprovecharse de una jovencita inocente. 476 00:27:07.040 --> 00:27:09.480 position:50.00%,middle align:middle size:80.00% line:84.67% Ya sabes, mente abierta, piernas cerradas. 477 00:27:09.640 --> 00:27:11.360 position:50.00%,middle align:middle size:80.00% line:79.33% - Buenas noches. - Buenas noches. 478 00:27:21.440 --> 00:27:23.280 position:50.00%,middle align:middle size:80.00% line:84.67% [riendo] ¿Qué? ¿Qué? ¿Qué? ¿Qué? 479 00:27:23.800 --> 00:27:26.320 position:50.00%,middle align:middle size:80.00% line:79.33% Estás hablando con el nuevo jefe técnico de la central 480 00:27:26.480 --> 00:27:29.120 position:50.00%,middle align:middle size:80.00% line:79.33% de la compañía de telefonía más importante de este país. 481 00:27:30.200 --> 00:27:31.920 position:50.00%,middle align:middle size:80.00% line:84.67% Pero ¿cuándo ha sido? ¿Cuándo ha sido? 482 00:27:32.080 --> 00:27:33.760 position:50.00%,middle align:middle size:80.00% line:79.33% Esta tarde me lo ha dicho don Francisco. 483 00:27:33.920 --> 00:27:35.520 position:50.00%,middle align:middle size:80.00% line:84.67% Digo: "Le doy un abrazo al estirado". 484 00:27:35.680 --> 00:27:37.080 position:50.00%,middle align:middle size:80.00% line:79.33% - ¡Mario! - ¿Qué, mi amor? 485 00:27:37.240 --> 00:27:39.600 position:50.00%,middle align:middle size:80.00% line:79.33% ¿Qué? No he dicho nada que todo el mundo no piense. 486 00:27:39.760 --> 00:27:41.440 position:50.00%,middle align:middle size:80.00% line:79.33% - Ay, qué bien. - Vamos a pagar la casa. 487 00:27:41.600 --> 00:27:44.120 position:50.00%,middle align:middle size:80.00% line:79.33% Vas a dejar de trabajar. Se acabaron los problemas. 488 00:27:44.280 --> 00:27:45.560 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Entiendes esto, amor? 489 00:27:45.720 --> 00:27:47.720 position:50.00%,middle align:middle size:80.00% line:84.67% Te quiero. Vamos a celebrar. 490 00:27:49.560 --> 00:27:50.920 position:50.00%,middle align:middle size:80.00% line:84.67% ¡Feliz, feliz! 491 00:27:51.600 --> 00:27:52.800 position:50.00%,middle align:middle size:80.00% line:84.67% Sí... 492 00:27:52.960 --> 00:27:55.800 position:50.00%,middle align:middle size:80.00% line:84.67% Mario, y... ¿Quieres que deje de trabajar? 493 00:27:55.960 --> 00:27:57.280 position:50.00%,middle align:middle size:80.00% line:84.67% Lo que habíamos hablado. 494 00:27:58.200 --> 00:27:59.280 position:50.00%,middle align:middle size:80.00% line:84.67% Ya, ya. 495 00:27:59.440 --> 00:28:01.520 position:50.00%,middle align:middle size:80.00% line:84.67% Sí, sí, sí, sé que lo hablamos, pero... 496 00:28:02.680 --> 00:28:05.960 position:50.00%,middle align:middle size:80.00% line:84.67% Eh. Pero es que eso era antes y... 497 00:28:07.240 --> 00:28:09.560 position:50.00%,middle align:middle size:80.00% line:84.67% Mario, a mí me gusta mucho mi trabajo. 498 00:28:11.000 --> 00:28:14.520 position:50.00%,middle align:middle size:80.00% line:79.33% ¿Te gusta tu trabajo más que estar en casa con tu marido y con tu hija? 499 00:28:14.680 --> 00:28:18.720 position:50.00%,middle align:middle size:80.00% line:79.33% No, Mario, mi hija y tú sois mi prioridad, eso siempre. 500 00:28:18.880 --> 00:28:21.240 position:50.00%,middle align:middle size:80.00% line:79.33% Si somos tu prioridad, ¿qué haces por ahí tomando vermú 501 00:28:21.400 --> 00:28:23.120 position:50.00%,middle align:middle size:80.00% line:84.67% mientras nosotros estamos en casa? 502 00:28:23.280 --> 00:28:25.680 position:50.00%,middle align:middle size:80.00% line:79.33% Mario, por Dios, es la primera vez en tres años. 503 00:28:32.320 --> 00:28:33.360 position:50.00%,middle align:middle size:80.00% line:84.67% Mario... 504 00:28:39.520 --> 00:28:40.680 position:50.00%,middle align:middle size:80.00% line:84.67% [portazo] 505 00:28:41.160 --> 00:28:42.360 position:50.00%,middle align:middle size:80.00% line:84.67% [suspira] 506 00:28:50.320 --> 00:28:51.960 position:50.00%,middle align:middle size:80.00% line:84.67% ¿No vas a bajar a cenar? 507 00:28:53.760 --> 00:28:55.880 position:50.00%,middle align:middle size:80.00% line:84.67% Esto lo hacemos por tu bien, hija. 508 00:28:56.520 --> 00:28:58.800 position:50.00%,middle align:middle size:80.00% line:79.33% ¿Y no ha pensado, madre, que a lo mejor se equivoca 509 00:28:58.960 --> 00:29:00.200 position:50.00%,middle align:middle size:80.00% line:84.67% con lo que es mejor para mí? 510 00:29:21.960 --> 00:29:24.480 position:50.00%,middle align:middle size:80.00% line:84.67% [música festiva y ambiente animado] 511 00:29:47.240 --> 00:29:49.640 position:50.00%,middle align:middle size:80.00% line:79.33% - [Marga] Madre mía, cuánta gente. - 800 trabajadores... 512 00:29:49.800 --> 00:29:52.080 position:50.00%,middle align:middle size:80.00% line:79.33% Con que haya venido solo una cuarta parte... 513 00:29:52.240 --> 00:29:54.920 position:50.00%,middle align:middle size:80.00% line:79.33% Si es que yo sabía que esto no podía perdérmelo... 514 00:29:55.080 --> 00:29:56.520 position:50.00%,middle align:middle size:80.00% line:84.67% [Marga] Aún no me creo que hayas venido. 515 00:29:56.680 --> 00:29:59.800 position:50.00%,middle align:middle size:80.00% line:79.33% Esta tarde, cuando tu padre entró en el bar, casi entro yo en el coche 516 00:29:59.960 --> 00:30:01.200 position:50.00%,middle align:middle size:80.00% line:84.67% del miedo que me dio. 517 00:30:01.360 --> 00:30:04.640 position:50.00%,middle align:middle size:80.00% line:79.33% ¿Tú sabes cuál es el peor enemigo de las mujeres, Marga? La sumisión. 518 00:30:04.800 --> 00:30:08.320 position:50.00%,middle align:middle size:80.00% line:79.33% Bueno, a mí siempre me han enseñado a obedecer. 519 00:30:08.480 --> 00:30:11.560 position:50.00%,middle align:middle size:80.00% line:79.33% Ya, y a mí. "Una mujer siempre tiene que ser correcta y obediente". 520 00:30:11.720 --> 00:30:14.640 position:50.00%,middle align:middle size:80.00% line:79.33% Pero ¿sabes qué? Que un día pensé: "Pero si tú no eres una mujer 521 00:30:14.800 --> 00:30:16.320 position:50.00%,middle align:middle size:80.00% line:84.67% para saber lo que yo necesito". 522 00:30:16.480 --> 00:30:19.720 position:50.00%,middle align:middle size:80.00% line:79.33% Puede cerrar todas las puertas que quiera porque "no hay barrera, 523 00:30:19.880 --> 00:30:23.240 position:50.00%,middle align:middle size:80.00% line:79.33% cerradura ni cerrojo que puedas imponer a la libertad de mi mente". 524 00:30:23.400 --> 00:30:27.040 position:50.00%,middle align:middle size:80.00% line:79.33% - Lo dijo Virginia Woolf. - ¿Esa es amiga tuya? 525 00:30:28.080 --> 00:30:29.840 position:50.00%,middle align:middle size:80.00% line:84.67% [aplausos] 526 00:30:31.880 --> 00:30:33.960 position:50.00%,middle align:middle size:80.00% line:84.67% [Carmen] Ni que fuéramos de la realeza. 527 00:30:34.120 --> 00:30:36.560 position:50.00%,middle align:middle size:80.00% line:79.33% ¿Desde cuándo te molesta ser el centro de atención? 528 00:30:36.720 --> 00:30:38.400 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Quién ha dicho que me moleste? 529 00:30:38.680 --> 00:30:41.400 position:50.00%,middle align:middle size:80.00% line:79.33% ¿Crees que alguna vez algo te parecerá bien, querida? 530 00:30:41.560 --> 00:30:44.880 position:50.00%,middle align:middle size:80.00% line:79.33% Sí, me pareció bien casarme contigo. Al principio. 531 00:30:45.040 --> 00:30:46.720 position:50.00%,middle align:middle size:80.00% line:84.67% Voy a saludar, ahora les veo. 532 00:30:51.480 --> 00:30:53.200 position:50.00%,middle align:middle size:80.00% line:84.67% [música de la fiesta] 533 00:31:03.520 --> 00:31:04.720 position:50.00%,middle align:middle size:80.00% line:84.67% [risas] 534 00:31:25.400 --> 00:31:26.440 position:50.00%,middle align:middle size:80.00% line:84.67% Buenas noches. 535 00:31:27.040 --> 00:31:28.080 position:50.00%,middle align:middle size:80.00% line:84.67% Disculpe. 536 00:31:28.240 --> 00:31:29.960 position:50.00%,middle align:middle size:80.00% line:84.67% Señorita... ¿Cómo era? 537 00:31:32.360 --> 00:31:34.000 position:50.00%,middle align:middle size:80.00% line:79.33% - Aguilar. [ríe] - Aguilar, eso es. 538 00:31:34.960 --> 00:31:37.440 position:50.00%,middle align:middle size:80.00% line:79.33% - [Carlos] Qué agradable sorpresa... - [Alba] Sí, ya he visto 539 00:31:37.600 --> 00:31:39.440 position:50.00%,middle align:middle size:80.00% line:84.67% que lo estaba pasando realmente mal. 540 00:31:39.600 --> 00:31:41.040 position:50.00%,middle align:middle size:80.00% line:84.67% Ah, ni siquiera he tenido tiempo. 541 00:31:41.200 --> 00:31:42.800 position:50.00%,middle align:middle size:80.00% line:84.67% Hay que ser amable con los empleados. 542 00:31:42.960 --> 00:31:44.920 position:50.00%,middle align:middle size:80.00% line:79.33% Eso es lo que hace conmigo, ser amable... 543 00:31:45.080 --> 00:31:47.960 position:50.00%,middle align:middle size:80.00% line:79.33% Hay cosas que se hacen por obligación y otras por placer. 544 00:31:48.120 --> 00:31:49.160 position:50.00%,middle align:middle size:80.00% line:84.67% Claramente 545 00:31:49.320 --> 00:31:50.760 position:50.00%,middle align:middle size:80.00% line:84.67% esta es una de las segundas. 546 00:31:53.040 --> 00:31:55.680 position:50.00%,middle align:middle size:80.00% line:79.33% Veo que esta noche está resultando divertida. 547 00:31:55.840 --> 00:31:58.480 position:50.00%,middle align:middle size:80.00% line:79.33% Señorita Aguilar, le presento a Francisco Gómez. 548 00:31:58.640 --> 00:32:00.600 position:50.00%,middle align:middle size:80.00% line:79.33% - Nos conocimos antes. - Sí. 549 00:32:01.680 --> 00:32:03.200 position:50.00%,middle align:middle size:80.00% line:79.33% - ¿Quiere tomar algo? - No, gracias. 550 00:32:03.360 --> 00:32:05.640 position:50.00%,middle align:middle size:80.00% line:79.33% ¿Un white lady? La bebida de las chicas de ciudad. 551 00:32:05.800 --> 00:32:07.640 position:50.00%,middle align:middle size:80.00% line:84.67% Esta noche se merece un buen brindis. 552 00:32:07.800 --> 00:32:10.680 position:50.00%,middle align:middle size:80.00% line:79.33% Y se lo agradezco, pero no me gusta el white lady. 553 00:32:10.840 --> 00:32:12.200 position:50.00%,middle align:middle size:80.00% line:84.67% Eso se llama sinceridad. 554 00:32:12.360 --> 00:32:14.840 position:50.00%,middle align:middle size:80.00% line:79.33% Es una cualidad muy poco extendida, por cierto. 555 00:32:15.000 --> 00:32:16.600 position:50.00%,middle align:middle size:80.00% line:84.67% Resulta que la señorita Aguilar 556 00:32:16.760 --> 00:32:19.720 position:50.00%,middle align:middle size:80.00% line:79.33% tiene de sobra para ti y para mí, además de otras muchas virtudes. 557 00:32:19.880 --> 00:32:21.120 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Cuáles? 558 00:32:21.280 --> 00:32:22.960 position:50.00%,middle align:middle size:80.00% line:84.67% [para la música] 559 00:32:23.120 --> 00:32:26.080 position:50.00%,middle align:middle size:80.00% line:79.33% Me parece que vamos a tener que esperar para conocerlas. 560 00:32:26.240 --> 00:32:27.480 position:50.00%,middle align:middle size:80.00% line:84.67% Cariño... 561 00:32:30.000 --> 00:32:33.400 position:50.00%,middle align:middle size:80.00% line:79.33% Mi padre me ha pedido que os avise. Os espera para subir al escenario. 562 00:32:37.040 --> 00:32:39.680 position:50.00%,middle align:middle size:80.00% line:79.33% [Carlos] Ni se mueva, señorita Aguilar, aún me debe un baile. 563 00:32:43.040 --> 00:32:45.440 position:50.00%,middle align:middle size:80.00% line:79.33% [Ricardo] Quiero agradecer a todos los presentes 564 00:32:45.600 --> 00:32:48.800 position:50.00%,middle align:middle size:80.00% line:79.33% que hayáis venido esta noche a compartir con nosotros 565 00:32:48.960 --> 00:32:52.560 position:50.00%,middle align:middle size:80.00% line:79.33% la inauguración de la mayor central de telefonía de Europa. 566 00:32:52.720 --> 00:32:56.160 position:50.00%,middle align:middle size:80.00% line:79.33% Todos sabéis que no ha sido un camino fácil ni corto 567 00:32:56.320 --> 00:32:59.560 position:50.00%,middle align:middle size:80.00% line:79.33% y que estas canas están directamente relacionadas con este camino. 568 00:32:59.720 --> 00:33:01.800 position:50.00%,middle align:middle size:80.00% line:84.67% Pero ya estamos aquí. 569 00:33:01.960 --> 00:33:03.320 position:50.00%,middle align:middle size:80.00% line:84.67% Y no lo estaríamos si no fuese 570 00:33:03.480 --> 00:33:06.440 position:50.00%,middle align:middle size:80.00% line:79.33% por la persona que ha impulsado personalmente este proyecto. 571 00:33:07.360 --> 00:33:08.480 position:50.00%,middle align:middle size:80.00% line:84.67% Es leal, 572 00:33:08.640 --> 00:33:10.680 position:50.00%,middle align:middle size:80.00% line:84.67% trabajador, honesto 573 00:33:11.040 --> 00:33:13.360 position:50.00%,middle align:middle size:80.00% line:84.67% y tengo la tranquilidad de saber 574 00:33:13.520 --> 00:33:16.840 position:50.00%,middle align:middle size:80.00% line:79.33% que dejo el futuro de la compañía en manos de mi yerno, 575 00:33:17.000 --> 00:33:19.040 position:50.00%,middle align:middle size:80.00% line:84.67% al que ya considero como mi propio hijo. 576 00:33:19.200 --> 00:33:20.600 position:50.00%,middle align:middle size:80.00% line:84.67% Francisco Gómez. Francisco. 577 00:33:28.320 --> 00:33:29.320 position:50.00%,middle align:middle size:80.00% line:84.67% [Francisco] Gracias. 578 00:33:29.480 --> 00:33:30.560 position:50.00%,middle align:middle size:80.00% line:84.67% Gracias. 579 00:33:30.720 --> 00:33:32.280 position:50.00%,middle align:middle size:80.00% line:84.67% Hace 10 años, ni siquiera 580 00:33:32.440 --> 00:33:34.120 position:50.00%,middle align:middle size:80.00% line:84.67% hubiera imaginado estar hoy aquí. 581 00:33:37.200 --> 00:33:38.760 position:50.00%,middle align:middle size:80.00% line:84.67% Me conformaba con un buen trabajo 582 00:33:38.920 --> 00:33:41.440 position:50.00%,middle align:middle size:80.00% line:79.33% y una habitación que compartir con el amor de mi vida. 583 00:33:44.680 --> 00:33:47.280 position:50.00%,middle align:middle size:80.00% line:79.33% Hoy tengo casi todo aquello con lo que había soñado. 584 00:33:48.320 --> 00:33:50.360 position:50.00%,middle align:middle size:80.00% line:84.67% Casi, porque siempre se puede 585 00:33:50.520 --> 00:33:51.800 position:50.00%,middle align:middle size:80.00% line:84.67% desear algo más. 586 00:33:52.560 --> 00:33:55.120 position:50.00%,middle align:middle size:80.00% line:79.33% Hoy es el principio de una nueva era para la compañía. 587 00:33:55.280 --> 00:33:57.240 position:50.00%,middle align:middle size:80.00% line:84.67% Una era que va a traspasar océanos. 588 00:33:57.400 --> 00:34:00.640 position:50.00%,middle align:middle size:80.00% line:79.33% Me complace anunciar que esta compañía va a realizar mañana 589 00:34:00.800 --> 00:34:03.000 position:50.00%,middle align:middle size:80.00% line:84.67% la primera llamada intercontinental. 590 00:34:03.160 --> 00:34:05.440 position:50.00%,middle align:middle size:80.00% line:79.33% El presidente Coolidge y su majestad Alfonso XIII 591 00:34:05.600 --> 00:34:07.840 position:50.00%,middle align:middle size:80.00% line:79.33% establecerán la primera conexión de la historia 592 00:34:08.000 --> 00:34:11.120 position:50.00%,middle align:middle size:80.00% line:79.33% entre España y los Estados Unidos de América. 593 00:34:19.080 --> 00:34:23.320 position:50.00%,middle align:middle size:80.00% line:79.33% [Alba] Había intentado convencerme durante años de que Francisco 594 00:34:23.480 --> 00:34:25.239 position:50.00%,middle align:middle size:80.00% line:84.67% ya no significaba nada para mí. 595 00:34:25.400 --> 00:34:28.800 position:50.00%,middle align:middle size:80.00% line:79.33% Si quería ser libre, no debía dejarme llevar por el corazón. 596 00:34:34.159 --> 00:34:35.360 position:50.00%,middle align:middle size:80.00% line:84.67% Esa noche 597 00:34:35.520 --> 00:34:38.760 position:50.00%,middle align:middle size:80.00% line:79.33% robaría el dinero de la compañía y huiría lejos 598 00:34:38.920 --> 00:34:41.639 position:50.00%,middle align:middle size:80.00% line:79.33% de cualquier fantasma del pasado, bueno o malo, 599 00:34:42.080 --> 00:34:44.280 position:50.00%,middle align:middle size:80.00% line:84.67% para ser... simplemente yo. 600 00:34:44.440 --> 00:34:46.679 position:50.00%,middle align:middle size:80.00% line:84.67% Disculpe, señorita, su identificación. 601 00:34:53.400 --> 00:34:54.800 position:50.00%,middle align:middle size:80.00% line:84.67% Aquí pone "turno de día". 602 00:34:54.960 --> 00:34:56.679 position:50.00%,middle align:middle size:80.00% line:84.67% No se permite el paso a ningún empleado 603 00:34:56.840 --> 00:34:58.400 position:50.00%,middle align:middle size:80.00% line:79.33% - que no sea del turno de noche. - Ya, ya. 604 00:34:58.560 --> 00:35:01.000 position:50.00%,middle align:middle size:80.00% line:79.33% Pero será solo un momento. Me he dejado el bolso. 605 00:35:01.160 --> 00:35:03.320 position:50.00%,middle align:middle size:80.00% line:79.33% Lo siento, pero tendrá que recogerlo mañana. 606 00:35:04.640 --> 00:35:06.720 position:50.00%,middle align:middle size:80.00% line:84.67% Es que me he dejado las llaves dentro. 607 00:35:09.200 --> 00:35:11.040 position:50.00%,middle align:middle size:80.00% line:84.67% Y no tengo a nadie a quien llamar. 608 00:35:12.000 --> 00:35:13.400 position:50.00%,middle align:middle size:80.00% line:84.67% Hágame el favor. 609 00:35:14.160 --> 00:35:17.040 position:50.00%,middle align:middle size:80.00% line:79.33% O, si no, me voy a quedar toda la noche en la calle. Será... 610 00:35:17.200 --> 00:35:18.480 position:50.00%,middle align:middle size:80.00% line:84.67% Será solo un momento. 611 00:36:06.960 --> 00:36:09.640 position:50.00%,middle align:middle size:80.00% line:79.33% Debemos ser los únicos a los que no han invitado a la fiesta. 612 00:36:09.800 --> 00:36:12.000 position:50.00%,middle align:middle size:80.00% line:79.33% Julián dice que han ido hasta los mozos de carga. 613 00:36:12.160 --> 00:36:13.720 position:50.00%,middle align:middle size:80.00% line:84.67% [puerta] 614 00:36:19.080 --> 00:36:20.080 position:50.00%,middle align:middle size:80.00% line:84.67% [música] 615 00:36:20.240 --> 00:36:21.600 position:50.00%,middle align:middle size:80.00% line:84.67% Esta me encanta. ¿La conoces? 616 00:36:21.760 --> 00:36:23.600 position:50.00%,middle align:middle size:80.00% line:79.33% - No. - Bueno, pues si tienes 617 00:36:23.760 --> 00:36:26.480 position:50.00%,middle align:middle size:80.00% line:79.33% un mal día, te pones esta canción y todos tus males se te pasan. 618 00:36:26.640 --> 00:36:29.080 position:50.00%,middle align:middle size:80.00% line:79.33% - Mira, escucha. - Creo que las hay mejores que esta, 619 00:36:29.240 --> 00:36:31.960 position:50.00%,middle align:middle size:80.00% line:79.33% pero si esta consigue que te olvides de tus problemas... 620 00:36:32.120 --> 00:36:34.480 position:50.00%,middle align:middle size:80.00% line:79.33% - ¿Cómo has salido del cuartel? - Por la ventana. 621 00:36:34.640 --> 00:36:36.240 position:50.00%,middle align:middle size:80.00% line:84.67% Qué dura es la vida del soldado. 622 00:36:36.400 --> 00:36:38.400 position:50.00%,middle align:middle size:80.00% line:84.67% Menos mal que las noches son 623 00:36:38.560 --> 00:36:39.680 position:50.00%,middle align:middle size:80.00% line:84.67% mucho más alegres. 624 00:36:39.840 --> 00:36:41.160 position:50.00%,middle align:middle size:80.00% line:84.67% Venga, vamos a bailar. 625 00:37:06.000 --> 00:37:07.480 position:50.00%,middle align:middle size:80.00% line:84.67% [jadeos] 626 00:37:41.920 --> 00:37:43.960 position:50.00%,middle align:middle size:80.00% line:79.33% - ¡Ah! - ¡Eh! ¡Oiga! 627 00:37:44.520 --> 00:37:45.600 position:50.00%,middle align:middle size:80.00% line:84.67% Deténgase. 628 00:37:47.000 --> 00:37:48.320 position:50.00%,middle align:middle size:80.00% line:84.67% [gruñe] 629 00:37:58.520 --> 00:37:59.840 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Está usted bien? 630 00:38:03.680 --> 00:38:06.560 position:50.00%,middle align:middle size:80.00% line:79.33% No he podido atraparle, pero al menos he conseguido su bolso. 631 00:38:06.720 --> 00:38:07.840 position:50.00%,middle align:middle size:80.00% line:84.67% Creo que está todo. 632 00:38:33.080 --> 00:38:34.240 position:50.00%,middle align:middle size:80.00% line:84.67% [Carlota] ¡Ah! 633 00:38:40.960 --> 00:38:42.640 position:50.00%,middle align:middle size:80.00% line:79.33% [risita] A ver qué excusa me invento mañana 634 00:38:42.800 --> 00:38:45.080 position:50.00%,middle align:middle size:80.00% line:79.33% para salir de aquí y llegar a tiempo al turno. 635 00:38:45.240 --> 00:38:48.000 position:50.00%,middle align:middle size:80.00% line:79.33% Sabes que no puedes estar así toda la vida... 636 00:38:48.160 --> 00:38:50.280 position:50.00%,middle align:middle size:80.00% line:79.33% Ya, pero es que no me queda otra alternativa. 637 00:38:50.440 --> 00:38:51.720 position:50.00%,middle align:middle size:80.00% line:79.33% - Sí que la tienes. - Ah, ¿sí? 638 00:38:51.880 --> 00:38:52.840 position:50.00%,middle align:middle size:80.00% line:84.67% Cásate conmigo. 639 00:38:53.000 --> 00:38:54.200 position:50.00%,middle align:middle size:80.00% line:84.67% [riendo] Estás loco. 640 00:38:54.360 --> 00:38:56.960 position:50.00%,middle align:middle size:80.00% line:79.33% Sí, yo tampoco pensé nunca que te fuese a decir esto, 641 00:38:57.120 --> 00:38:58.160 position:50.00%,middle align:middle size:80.00% line:84.67% pero es la solución. 642 00:38:58.320 --> 00:39:00.400 position:50.00%,middle align:middle size:80.00% line:84.67% Ah, que es la solución, ¿eh? Sí. 643 00:39:02.640 --> 00:39:05.440 position:50.00%,middle align:middle size:80.00% line:79.33% - No dependerías de tu padre. - No, no dependería de mi padre. 644 00:39:05.600 --> 00:39:07.000 position:50.00%,middle align:middle size:80.00% line:84.67% Dependería de ti, mi marido, 645 00:39:07.160 --> 00:39:09.400 position:50.00%,middle align:middle size:80.00% line:79.33% que es el perfecto sinónimo de independencia. 646 00:39:09.560 --> 00:39:12.040 position:50.00%,middle align:middle size:80.00% line:79.33% Y te crees que yo te voy a decir lo que tienes que hacer... 647 00:39:12.840 --> 00:39:14.320 position:50.00%,middle align:middle size:80.00% line:84.67% Estás borracho, me voy a casa. 648 00:39:14.480 --> 00:39:16.960 position:50.00%,middle align:middle size:80.00% line:84.67% Sí, estoy borracho, pero tengo razón. 649 00:39:17.280 --> 00:39:18.920 position:50.00%,middle align:middle size:80.00% line:84.67% Piénsatelo. 650 00:39:26.480 --> 00:39:28.440 position:50.00%,middle align:middle size:80.00% line:84.67% [motor a lo lejos] 651 00:39:29.840 --> 00:39:31.040 position:50.00%,middle align:middle size:80.00% line:84.67% [hombre] ¡Vamos, Miguel! 652 00:39:32.680 --> 00:39:34.080 position:50.00%,middle align:middle size:80.00% line:84.67% [Miguel] Buenas noches. 653 00:39:34.320 --> 00:39:35.800 position:50.00%,middle align:middle size:80.00% line:84.67% Eh, esperadme, chicos. 654 00:39:50.920 --> 00:39:52.640 position:50.00%,middle align:middle size:80.00% line:84.67% [pequeños golpes] 655 00:40:00.320 --> 00:40:01.520 position:50.00%,middle align:middle size:80.00% line:84.67% [suspira] 656 00:40:07.560 --> 00:40:08.760 position:50.00%,middle align:middle size:80.00% line:84.67% Perdona. No hagas ruido. 657 00:40:08.920 --> 00:40:11.640 position:50.00%,middle align:middle size:80.00% line:79.33% Gracias, Marga. Es que no sabía dónde estaba Miguel. 658 00:40:11.800 --> 00:40:14.480 position:50.00%,middle align:middle size:80.00% line:79.33% No te preocupes, hay dos camas, y las dos son para ti si quieres. 659 00:40:14.640 --> 00:40:16.240 position:50.00%,middle align:middle size:80.00% line:79.33% - Yo mañana me voy al pueblo. - ¿Por qué? 660 00:40:16.400 --> 00:40:18.000 position:50.00%,middle align:middle size:80.00% line:84.67% Porque esto no es para mí, Carlota. 661 00:40:18.160 --> 00:40:20.760 position:50.00%,middle align:middle size:80.00% line:79.33% Me he perdido, se me ha roto la maleta, me han robado el bolso. 662 00:40:20.920 --> 00:40:22.440 position:50.00%,middle align:middle size:80.00% line:79.33% - ¿Te han robado? - Sí, pero da igual. 663 00:40:22.600 --> 00:40:23.960 position:50.00%,middle align:middle size:80.00% line:84.67% Yo no quiero estar aquí. 664 00:40:24.120 --> 00:40:26.760 position:50.00%,middle align:middle size:80.00% line:79.33% Ay, Marga, ¿tú sabes cuánto tiempo estuve fingiendo 665 00:40:26.920 --> 00:40:28.320 position:50.00%,middle align:middle size:80.00% line:84.67% que iba a clase de labores? 666 00:40:28.600 --> 00:40:29.880 position:50.00%,middle align:middle size:80.00% line:84.67% Un año. 667 00:40:30.040 --> 00:40:32.400 position:50.00%,middle align:middle size:80.00% line:79.33% Un año mientras me pagaba las clases de mecanografía. 668 00:40:32.560 --> 00:40:35.800 position:50.00%,middle align:middle size:80.00% line:79.33% ¿Y por qué? No quería que mis padres se enteraran de que quería trabajar. 669 00:40:35.960 --> 00:40:37.640 position:50.00%,middle align:middle size:80.00% line:84.67% Tenía miedo de que me echaran de casa. 670 00:40:39.320 --> 00:40:40.600 position:50.00%,middle align:middle size:80.00% line:84.67% Vaya, lo siento. 671 00:40:41.440 --> 00:40:42.760 position:50.00%,middle align:middle size:80.00% line:84.67% Pero ¿lo siento por qué? 672 00:40:43.560 --> 00:40:45.120 position:50.00%,middle align:middle size:80.00% line:84.67% De lo siento nada, no. 673 00:40:45.520 --> 00:40:48.440 position:50.00%,middle align:middle size:80.00% line:79.33% ¿Sabes cuál es el lado bueno? Que ya no tengo miedo. 674 00:40:50.160 --> 00:40:51.080 position:50.00%,middle align:middle size:80.00% line:84.67% De verdad, Carlota, 675 00:40:51.240 --> 00:40:53.560 position:50.00%,middle align:middle size:80.00% line:79.33% que no sé cómo te puedes tomar las cosas así de bien. 676 00:40:53.720 --> 00:40:55.840 position:50.00%,middle align:middle size:80.00% line:79.33% ¿Y qué quieres que haga, que me ponga a llorar 677 00:40:56.000 --> 00:40:58.760 position:50.00%,middle align:middle size:80.00% line:79.33% porque mis padres prefieren sus ideas a una hija como yo? 678 00:40:58.920 --> 00:41:00.680 position:50.00%,middle align:middle size:80.00% line:84.67% ¿De qué iba a servir? De nada. Mira. 679 00:41:00.840 --> 00:41:02.400 position:50.00%,middle align:middle size:80.00% line:84.67% Hay dos maneras de ver la vida. 680 00:41:02.560 --> 00:41:05.440 position:50.00%,middle align:middle size:80.00% line:79.33% Puedes ver el lado negativo, ponerte a llorar, protestar... 681 00:41:05.600 --> 00:41:07.000 position:50.00%,middle align:middle size:80.00% line:84.67% O puedes ver el lado divertido, 682 00:41:07.160 --> 00:41:09.240 position:50.00%,middle align:middle size:80.00% line:84.67% disfrutar, sonreír, y tirar hacia delante. 683 00:41:09.400 --> 00:41:10.800 position:50.00%,middle align:middle size:80.00% line:84.67% Y ese es el que he elegido yo. 684 00:41:12.440 --> 00:41:15.120 position:50.00%,middle align:middle size:80.00% line:79.33% Todo en la vida tiene algo bueno, hasta Madrid. 685 00:41:16.280 --> 00:41:18.480 position:50.00%,middle align:middle size:80.00% line:84.67% Aunque... no lo veas. 686 00:41:22.160 --> 00:41:24.600 position:50.00%,middle align:middle size:80.00% line:84.67% Si no robo esa caja durante la llamada, 687 00:41:25.720 --> 00:41:28.920 position:50.00%,middle align:middle size:80.00% line:79.33% creo que vas a tener que buscarte otro hogar. 688 00:41:31.480 --> 00:41:33.400 position:50.00%,middle align:middle size:80.00% line:84.67% Beltrán no me va a dar más tiempo. 689 00:41:35.520 --> 00:41:37.600 position:50.00%,middle align:middle size:80.00% line:84.67% O entrego ese dinero mañana a la una... 690 00:41:39.480 --> 00:41:41.160 position:50.00%,middle align:middle size:80.00% line:84.67% Esta es mi última oportunidad. 691 00:41:43.040 --> 00:41:46.200 position:50.00%,middle align:middle size:80.00% line:79.33% Extra, extra. Hoy se realiza la primera llamada a América. 692 00:41:46.360 --> 00:41:49.960 position:50.00%,middle align:middle size:80.00% line:79.33% Extra, extra. Hoy el rey llamará a América. 693 00:42:00.200 --> 00:42:01.880 position:50.00%,middle align:middle size:80.00% line:84.67% Mario, ¿puedo hablar contigo? 694 00:42:02.040 --> 00:42:04.520 position:50.00%,middle align:middle size:80.00% line:79.33% - Tengo mucho trabajo, ahora no. - He estado toda la noche 695 00:42:04.680 --> 00:42:06.560 position:50.00%,middle align:middle size:80.00% line:84.67% en vilo sin saber si estabas bien. 696 00:42:07.120 --> 00:42:09.040 position:50.00%,middle align:middle size:80.00% line:79.33% - ¿Dónde has estado? - Caminando. 697 00:42:10.320 --> 00:42:11.640 position:50.00%,middle align:middle size:80.00% line:84.67% Toda la noche... 698 00:42:11.800 --> 00:42:14.160 position:50.00%,middle align:middle size:80.00% line:79.33% Toda la noche. Tenía muchas cosas en las que pensar. 699 00:42:15.480 --> 00:42:16.520 position:50.00%,middle align:middle size:80.00% line:84.67% Mario... 700 00:42:16.680 --> 00:42:19.560 position:50.00%,middle align:middle size:80.00% line:79.33% Ángeles, no voy a volver a discutir contigo y menos aquí. 701 00:42:20.120 --> 00:42:23.720 position:50.00%,middle align:middle size:80.00% line:79.33% Es que no quiero discutir, de verdad, solo quiero que hablemos. 702 00:42:23.880 --> 00:42:25.000 position:50.00%,middle align:middle size:80.00% line:84.67% Por favor. 703 00:42:35.320 --> 00:42:36.360 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Quieres hablar? 704 00:42:37.480 --> 00:42:38.520 position:50.00%,middle align:middle size:80.00% line:84.67% Habla. 705 00:42:39.120 --> 00:42:41.600 position:50.00%,middle align:middle size:80.00% line:84.67% Mario, de verdad, ¿tú no puedes entender 706 00:42:41.760 --> 00:42:45.080 position:50.00%,middle align:middle size:80.00% line:79.33% que me haya molestado que me pidas que deje mi trabajo? 707 00:42:45.240 --> 00:42:47.400 position:50.00%,middle align:middle size:80.00% line:79.33% No me lo puedo creer. Pero ¿tú qué te piensas, 708 00:42:47.560 --> 00:42:50.560 position:50.00%,middle align:middle size:80.00% line:79.33% que yo no me he dado cuenta de lo mucho que te gusta tu trabajo? 709 00:42:50.720 --> 00:42:53.000 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Lo que te está costando dejar todo esto? 710 00:42:53.800 --> 00:42:55.320 position:50.00%,middle align:middle size:80.00% line:84.67% Pero nosotros tenemos una hija, 711 00:42:55.480 --> 00:42:58.400 position:50.00%,middle align:middle size:80.00% line:79.33% Ángeles, y yo no quiero que a mi hija la cuide una desconocida, 712 00:42:58.560 --> 00:42:59.960 position:50.00%,middle align:middle size:80.00% line:84.67% quiero que la cuide su madre. 713 00:43:00.120 --> 00:43:01.640 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Te has parado a pensar lo que sufro 714 00:43:01.800 --> 00:43:04.080 position:50.00%,middle align:middle size:80.00% line:79.33% porque no le puedo dar a mi familia lo que se merece? 715 00:43:04.240 --> 00:43:06.080 position:50.00%,middle align:middle size:80.00% line:84.67% Te prometí que no te iba a faltar de nada, 716 00:43:06.240 --> 00:43:07.720 position:50.00%,middle align:middle size:80.00% line:84.67% que te iba a tratar como una reina. 717 00:43:07.880 --> 00:43:10.280 position:50.00%,middle align:middle size:80.00% line:79.33% ¿Y qué he conseguido? Que te pases el día trabajando, 718 00:43:10.440 --> 00:43:11.800 position:50.00%,middle align:middle size:80.00% line:84.67% cogiendo llamadas. 719 00:43:15.480 --> 00:43:18.440 position:50.00%,middle align:middle size:80.00% line:79.33% Ángeles, tú te has convencido de que todo esto está bien. 720 00:43:18.600 --> 00:43:19.680 position:50.00%,middle align:middle size:80.00% line:84.67% No lo está. 721 00:43:22.280 --> 00:43:23.640 position:50.00%,middle align:middle size:80.00% line:84.67% Déjame hacerte feliz. 722 00:43:25.080 --> 00:43:26.080 position:50.00%,middle align:middle size:80.00% line:84.67% Por favor. 723 00:43:28.960 --> 00:43:31.040 position:50.00%,middle align:middle size:80.00% line:84.67% Seamos la familia que siempre soñamos ser. 724 00:43:44.040 --> 00:43:46.400 position:50.00%,middle align:middle size:80.00% line:10.00% [puerta abriéndose] 725 00:43:47.320 --> 00:43:48.440 position:50.00%,middle align:middle size:80.00% line:84.67% [Carlos] Buenos días. 726 00:43:58.120 --> 00:44:01.160 position:50.00%,middle align:middle size:80.00% line:79.33% - ¿Se puede saber qué estás buscando? - Las llaves de la caja fuerte, 727 00:44:01.320 --> 00:44:02.960 position:50.00%,middle align:middle size:80.00% line:84.67% que me las olvidé aquí o las perdí ayer, 728 00:44:03.120 --> 00:44:05.040 position:50.00%,middle align:middle size:80.00% line:79.33% donde quiera que haya estado toda la noche. 729 00:44:05.200 --> 00:44:08.240 position:50.00%,middle align:middle size:80.00% line:79.33% Algún día deberías de probar a beber dos copas menos de coñac. 730 00:44:08.400 --> 00:44:11.080 position:50.00%,middle align:middle size:80.00% line:79.33% - Le vendría bien a tu memoria. - Y fatal a mi alma. 731 00:44:11.240 --> 00:44:13.480 position:50.00%,middle align:middle size:80.00% line:79.33% Es libre, no puedo estar poniéndole cortapisas. 732 00:44:14.280 --> 00:44:15.360 position:50.00%,middle align:middle size:80.00% line:84.67% Ya. 733 00:44:15.600 --> 00:44:17.960 position:50.00%,middle align:middle size:80.00% line:79.33% Y tú algún deberías probar a soltar alguna sonrisa. 734 00:44:19.600 --> 00:44:20.760 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Estás bien? 735 00:44:23.360 --> 00:44:24.880 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Recuerdas a Alba? 736 00:44:25.040 --> 00:44:26.120 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Alba? 737 00:44:31.480 --> 00:44:33.000 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Tu amor de la adolescencia, 738 00:44:33.160 --> 00:44:35.240 position:50.00%,middle align:middle size:80.00% line:84.67% la que desapareció sin despedirse, 739 00:44:35.400 --> 00:44:37.080 position:50.00%,middle align:middle size:80.00% line:84.67% la que se escapó corriendo de ti? 740 00:44:37.240 --> 00:44:38.560 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Esa? No. 741 00:44:39.360 --> 00:44:41.440 position:50.00%,middle align:middle size:80.00% line:79.33% - Ayer me pareció verla. - ¿Otra vez? 742 00:44:41.600 --> 00:44:42.720 position:50.00%,middle align:middle size:80.00% line:84.67% Se parecía mucho. 743 00:44:43.280 --> 00:44:45.360 position:50.00%,middle align:middle size:80.00% line:84.67% Francisco, la gente en diez años cambia. 744 00:44:46.320 --> 00:44:48.000 position:50.00%,middle align:middle size:80.00% line:84.67% Mucho. Mírate tú. 745 00:44:48.560 --> 00:44:50.440 position:50.00%,middle align:middle size:80.00% line:84.67% [ríe] 746 00:44:50.520 --> 00:44:52.080 position:50.00%,middle align:middle size:80.00% line:84.67% Sí, tienes razón, olvídalo. 747 00:44:52.360 --> 00:44:53.800 position:50.00%,middle align:middle size:80.00% line:84.67% Ha sido una tontería. 748 00:44:54.480 --> 00:44:56.560 position:50.00%,middle align:middle size:80.00% line:84.67% Además, hay mucho que hacer. 749 00:44:57.920 --> 00:44:59.440 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Lo suficientemente elegante 750 00:44:59.600 --> 00:45:01.960 position:50.00%,middle align:middle size:80.00% line:79.33% como para ser inmortalizado junto a su majestad? 751 00:45:02.400 --> 00:45:03.640 position:50.00%,middle align:middle size:80.00% line:84.67% Si no tuvieras esas ojeras, 752 00:45:03.800 --> 00:45:05.600 position:50.00%,middle align:middle size:80.00% line:84.67% hasta creería que eres un hombre formal. 753 00:45:06.720 --> 00:45:08.560 position:50.00%,middle align:middle size:80.00% line:84.67% Hay... mucho que hacer. 754 00:45:09.120 --> 00:45:10.960 position:50.00%,middle align:middle size:80.00% line:84.67% [pisa fuerte] Orden del día. 755 00:45:11.120 --> 00:45:12.640 position:50.00%,middle align:middle size:80.00% line:84.67% Antes de nada, comunicarle a Sara 756 00:45:12.800 --> 00:45:14.960 position:50.00%,middle align:middle size:80.00% line:79.33% quién será la encargada de efectuar la llamada. 757 00:45:15.120 --> 00:45:16.160 position:50.00%,middle align:middle size:80.00% line:84.67% Ya me he encargado yo. 758 00:45:17.160 --> 00:45:18.440 position:50.00%,middle align:middle size:80.00% line:84.67% Personalmente. 759 00:45:24.320 --> 00:45:25.360 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Qué? 760 00:45:25.840 --> 00:45:27.920 position:50.00%,middle align:middle size:80.00% line:79.33% - Siento el retraso. - ¿Te lo has pensado mejor? 761 00:45:28.280 --> 00:45:29.280 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Qué está pasando? 762 00:45:29.440 --> 00:45:32.280 position:50.00%,middle align:middle size:80.00% line:79.33% Ah, van a anunciar la telefonista que va a hacer la llamada del rey. 763 00:45:32.440 --> 00:45:34.840 position:50.00%,middle align:middle size:80.00% line:79.33% Se lo darán a Ángeles, es la que tiene más experiencia. 764 00:45:35.000 --> 00:45:36.000 position:50.00%,middle align:middle size:80.00% line:84.67% Señoritas... 765 00:45:36.160 --> 00:45:38.400 position:50.00%,middle align:middle size:80.00% line:84.67% No vamos a demorar más este momento. 766 00:45:38.560 --> 00:45:40.080 position:50.00%,middle align:middle size:80.00% line:84.67% Todas saben que hoy se llevará a cabo 767 00:45:40.240 --> 00:45:41.920 position:50.00%,middle align:middle size:80.00% line:84.67% la conexión transoceánica entre el rey 768 00:45:42.080 --> 00:45:44.280 position:50.00%,middle align:middle size:80.00% line:84.67% y el presidente de los EE. UU. 769 00:45:44.440 --> 00:45:47.320 position:50.00%,middle align:middle size:80.00% line:79.33% Hay que designar a la telefonista que llevará a cabo dicha conexión. 770 00:45:47.480 --> 00:45:48.880 position:50.00%,middle align:middle size:80.00% line:84.67% Es una gran responsabilidad, 771 00:45:49.040 --> 00:45:51.680 position:50.00%,middle align:middle size:80.00% line:79.33% así que espero que la persona elegida se lo tome como tal. 772 00:45:51.840 --> 00:45:55.400 position:50.00%,middle align:middle size:80.00% line:79.33% La telefonista elegida es... Lidia Aguilar. 773 00:45:55.880 --> 00:45:57.000 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Yo? 774 00:45:58.080 --> 00:45:59.320 position:50.00%,middle align:middle size:80.00% line:84.67% No puedo hacerlo. 775 00:45:59.480 --> 00:46:00.760 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Perdone, señorita Aguilar? 776 00:46:00.920 --> 00:46:04.320 position:50.00%,middle align:middle size:80.00% line:79.33% Acabo de llegar, no conozco el puesto, debería hacerlo alguien 777 00:46:04.480 --> 00:46:05.440 position:50.00%,middle align:middle size:80.00% line:84.67% con más experiencia. 778 00:46:05.600 --> 00:46:07.520 position:50.00%,middle align:middle size:80.00% line:79.33% - Ángeles, por ejemplo. - Estoy de acuerdo. 779 00:46:07.680 --> 00:46:10.160 position:50.00%,middle align:middle size:80.00% line:79.33% Pero la decisión no ha sido mía, son órdenes de Dirección. 780 00:46:10.320 --> 00:46:12.000 position:50.00%,middle align:middle size:80.00% line:84.67% Espero que cumpla con su cometido. 781 00:46:12.840 --> 00:46:14.840 position:50.00%,middle align:middle size:80.00% line:84.67% Aquí tiene, el protocolo de actuación. 782 00:46:15.000 --> 00:46:17.240 position:50.00%,middle align:middle size:80.00% line:79.33% Apréndaselo como si le fuese la vida en ello. 783 00:46:17.400 --> 00:46:18.960 position:50.00%,middle align:middle size:80.00% line:84.67% Porque le aseguro que le va. 784 00:46:20.360 --> 00:46:21.480 position:50.00%,middle align:middle size:80.00% line:84.67% El resto de centralitas 785 00:46:21.640 --> 00:46:24.160 position:50.00%,middle align:middle size:80.00% line:79.33% se desconectarán durante un periodo de 10 minutos. 786 00:46:24.320 --> 00:46:26.800 position:50.00%,middle align:middle size:80.00% line:79.33% Así que todas ustedes no tendrán que hacer otra cosa más 787 00:46:26.960 --> 00:46:28.400 position:50.00%,middle align:middle size:80.00% line:84.67% que presenciar este momento. 788 00:46:34.880 --> 00:46:37.520 position:50.00%,middle align:middle size:80.00% line:79.33% - No voy a hacerlo. - Señorita, el protocolo es sencillo. 789 00:46:37.680 --> 00:46:39.760 position:50.00%,middle align:middle size:80.00% line:84.67% Y ayer se manejaba bien con las clavijas. 790 00:46:39.920 --> 00:46:40.920 position:50.00%,middle align:middle size:80.00% line:84.67% No me refiero... 791 00:46:41.080 --> 00:46:43.960 position:50.00%,middle align:middle size:80.00% line:79.33% Si falla cualquier cosa, habrá más telefonistas para cubrirla. 792 00:46:48.400 --> 00:46:50.520 position:50.00%,middle align:middle size:80.00% line:79.33% Es un momento muy importante para la compañía. 793 00:46:50.680 --> 00:46:53.400 position:50.00%,middle align:middle size:80.00% line:79.33% Vas a ponerlo en peligro para conquistar a una telefonista. 794 00:46:53.560 --> 00:46:55.120 position:50.00%,middle align:middle size:80.00% line:84.67% Me estás hablando como mi padre. 795 00:46:55.280 --> 00:46:58.600 position:50.00%,middle align:middle size:80.00% line:79.33% ¿De verdad piensas eso, piensas que lo hago solo para conquistarla? 796 00:46:58.760 --> 00:46:59.760 position:50.00%,middle align:middle size:80.00% line:84.67% Carlos... 797 00:46:59.920 --> 00:47:02.200 position:50.00%,middle align:middle size:80.00% line:79.33% Me gusta la imagen que proyecta para la empresa. 798 00:47:02.360 --> 00:47:04.720 position:50.00%,middle align:middle size:80.00% line:79.33% Vamos, es una chica ambiciosa. Creo que lo hará bien. 799 00:47:04.880 --> 00:47:06.360 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Por qué no me consultaste nada? 800 00:47:06.520 --> 00:47:08.440 position:50.00%,middle align:middle size:80.00% line:84.67% No sabía que tenía que consultarte todo. 801 00:47:10.480 --> 00:47:12.200 position:50.00%,middle align:middle size:80.00% line:84.67% Además, de las selecciones de personal 802 00:47:12.360 --> 00:47:13.600 position:50.00%,middle align:middle size:80.00% line:84.67% siempre me he encargado yo. 803 00:47:15.680 --> 00:47:17.800 position:50.00%,middle align:middle size:80.00% line:84.67% Confía en mí, por favor. 804 00:47:19.520 --> 00:47:21.840 position:50.00%,middle align:middle size:80.00% line:79.33% La expectación es máxima entre los medios reunidos 805 00:47:22.000 --> 00:47:24.440 position:50.00%,middle align:middle size:80.00% line:79.33% en esta décima planta de la compañía telefónica. 806 00:47:24.600 --> 00:47:27.520 position:50.00%,middle align:middle size:80.00% line:79.33% Hoy, a las 12 en punto del mediodía, se producirá 807 00:47:27.680 --> 00:47:29.720 position:50.00%,middle align:middle size:80.00% line:84.67% un hecho que marcará un antes y un después 808 00:47:29.880 --> 00:47:30.880 position:50.00%,middle align:middle size:80.00% line:84.67% en la historia: 809 00:47:31.040 --> 00:47:33.640 position:50.00%,middle align:middle size:80.00% line:79.33% la primera llamada telefónica intercontinental realizada 810 00:47:33.800 --> 00:47:34.880 position:50.00%,middle align:middle size:80.00% line:84.67% desde nuestro país 811 00:47:35.040 --> 00:47:37.560 position:50.00%,middle align:middle size:80.00% line:79.33% y para la que se cuenta con dos anfitriones de lujo 812 00:47:37.720 --> 00:47:39.400 position:50.00%,middle align:middle size:80.00% line:84.67% a ambos lados del Atlántico. 813 00:47:41.760 --> 00:47:42.880 position:50.00%,middle align:middle size:80.00% line:84.67% [vítores y aplausos] 814 00:47:43.040 --> 00:47:44.480 position:50.00%,middle align:middle size:80.00% line:84.67% Su majestad, Alfonso XIII, 815 00:47:44.640 --> 00:47:47.280 position:50.00%,middle align:middle size:80.00% line:79.33% cuya llegada al palacio de la Compañía de Telefonía 816 00:47:47.440 --> 00:47:49.120 position:50.00%,middle align:middle size:80.00% line:84.67% estamos a punto de presenciar. 817 00:47:49.280 --> 00:47:51.600 position:50.00%,middle align:middle size:80.00% line:84.67% [vítores y aplausos] 818 00:47:52.720 --> 00:47:55.360 position:50.00%,middle align:middle size:80.00% line:79.33% Conversará con el presidente Coolidge en una conexión 819 00:47:55.520 --> 00:47:56.640 position:50.00%,middle align:middle size:80.00% line:84.67% que no hace sino confirmar 820 00:47:56.800 --> 00:47:59.280 position:50.00%,middle align:middle size:80.00% line:79.33% las buenas relaciones existentes entre ambos países. 821 00:48:02.240 --> 00:48:04.040 position:50.00%,middle align:middle size:80.00% line:84.67% [Alba] No tenía miedo a asumir ese reto, 822 00:48:04.200 --> 00:48:06.920 position:50.00%,middle align:middle size:80.00% line:79.33% pero hacerlo complicaba mi principal objetivo. 823 00:48:10.120 --> 00:48:12.720 position:50.00%,middle align:middle size:80.00% line:79.33% En aquel instante en el que el mundo entero 824 00:48:12.880 --> 00:48:16.280 position:50.00%,middle align:middle size:80.00% line:79.33% estaría pendiente del gran acontecimiento histórico, 825 00:48:17.040 --> 00:48:18.560 position:50.00%,middle align:middle size:80.00% line:84.67% yo robaría la caja fuerte. 826 00:48:19.240 --> 00:48:20.840 position:50.00%,middle align:middle size:80.00% line:84.67% Acabaría con el chantaje de Beltrán 827 00:48:21.000 --> 00:48:23.920 position:50.00%,middle align:middle size:80.00% line:79.33% y huiría en busca de la nueva vida con la que siempre había soñado, 828 00:48:24.880 --> 00:48:28.280 position:50.00%,middle align:middle size:80.00% line:84.67% lejos de mi pasado... y del que un día 829 00:48:28.440 --> 00:48:30.360 position:50.00%,middle align:middle size:80.00% line:84.67% había sido el amor de mi vida. 830 00:48:45.120 --> 00:48:46.600 position:50.00%,middle align:middle size:80.00% line:84.67% Ya es la hora, padre. 831 00:48:46.920 --> 00:48:48.000 position:50.00%,middle align:middle size:80.00% line:84.67% Bien. 832 00:48:48.160 --> 00:48:51.440 position:50.00%,middle align:middle size:80.00% line:79.33% Majestad, por favor, tiene el mundo a sus pies. 833 00:49:11.000 --> 00:49:14.080 position:50.00%,middle align:middle size:80.00% line:79.33% Buenos días. Compañía de Telefonía, ¿en qué puedo ayudarle? 834 00:49:14.240 --> 00:49:16.080 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Sí? ¿Operadora? 835 00:49:16.440 --> 00:49:18.120 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Podría hacer el favor 836 00:49:18.280 --> 00:49:21.480 position:50.00%,middle align:middle size:80.00% line:84.67% de comunicarme con Washington, EE. UU.? 837 00:49:21.640 --> 00:49:24.280 position:50.00%,middle align:middle size:80.00% line:84.67% Número 0002, con el presidente. 838 00:49:24.440 --> 00:49:26.160 position:50.00%,middle align:middle size:80.00% line:84.67% Por supuesto, su majestad. 839 00:49:26.320 --> 00:49:27.400 position:50.00%,middle align:middle size:80.00% line:84.67% Le comunico. 840 00:49:27.720 --> 00:49:29.560 position:50.00%,middle align:middle size:80.00% line:84.67% Manténgase a la espera, por favor. 841 00:49:41.000 --> 00:49:43.040 position:50.00%,middle align:middle size:80.00% line:84.67% [bobina] 842 00:50:04.720 --> 00:50:06.280 position:50.00%,middle align:middle size:80.00% line:84.67% He perdido la línea. 843 00:50:06.440 --> 00:50:09.440 position:50.00%,middle align:middle size:80.00% line:79.33% [Francisco] Hemos revisado todos los circuitos. Tiene que haber señal. 844 00:50:09.600 --> 00:50:10.840 position:50.00%,middle align:middle size:80.00% line:84.67% [Sara] Algo está haciendo mal. 845 00:50:12.600 --> 00:50:13.880 position:50.00%,middle align:middle size:80.00% line:84.67% [Alba] No hay señal. 846 00:50:14.040 --> 00:50:16.880 position:50.00%,middle align:middle size:80.00% line:79.33% - [Alba] No hay señal. - Clavija uno, clavija... 847 00:50:17.040 --> 00:50:18.160 position:50.00%,middle align:middle size:80.00% line:84.67% [Ángeles] Está apagada. 848 00:50:38.240 --> 00:50:39.560 position:50.00%,middle align:middle size:80.00% line:84.67% [en inglés] ¿Hola? 849 00:50:40.320 --> 00:50:41.960 position:50.00%,middle align:middle size:80.00% line:84.67% ¿Washington DC? 850 00:50:51.160 --> 00:50:54.160 position:50.00%,middle align:middle size:80.00% line:79.33% Un día para la historia, el triunfo de las comunicaciones. 851 00:50:56.240 --> 00:50:58.840 position:50.00%,middle align:middle size:80.00% line:79.33% El mundo en manos de una mujer: una telefonista. 852 00:50:59.000 --> 00:51:01.480 position:50.00%,middle align:middle size:80.00% line:79.33% [Alfonso XIII en inglés] Gracias. Muchas gracias, señor... 853 00:51:01.640 --> 00:51:03.400 position:50.00%,middle align:middle size:80.00% line:84.67% Todos celebraban ese hecho insólito 854 00:51:03.560 --> 00:51:05.360 position:50.00%,middle align:middle size:80.00% line:84.67% sintiéndose parte de un nuevo mundo. 855 00:51:05.520 --> 00:51:07.760 position:50.00%,middle align:middle size:80.00% line:84.67% Aunque algunas ya empezaban a comprender 856 00:51:07.920 --> 00:51:09.920 position:50.00%,middle align:middle size:80.00% line:84.67% que ese nuevo mundo no era tan idílico 857 00:51:10.080 --> 00:51:12.360 position:50.00%,middle align:middle size:80.00% line:84.67% ni tan sencillo como habrían deseado. 858 00:51:12.520 --> 00:51:16.800 position:50.00%,middle align:middle size:80.00% line:79.33% Que implicaría renuncias familiares, soledad... 859 00:51:17.720 --> 00:51:20.560 position:50.00%,middle align:middle size:80.00% line:79.33% Porque la independencia siempre tiene un precio. 860 00:51:22.760 --> 00:51:25.360 position:50.00%,middle align:middle size:80.00% line:84.67% El de Gimena fue su vida, y el mío... 861 00:51:26.400 --> 00:51:28.400 position:50.00%,middle align:middle size:80.00% line:84.67% estar condenada a romper las normas. 862 00:52:03.000 --> 00:52:04.160 position:50.00%,middle align:middle size:80.00% line:84.67% Detente. 863 00:52:14.720 --> 00:52:16.000 position:50.00%,middle align:middle size:80.00% line:84.67% Sé quién eres. 864 00:52:23.520 --> 00:52:25.840 position:50.00%,middle align:middle size:80.00% line:79.33% Diez años no son suficientes para olvidarte... 865 00:52:27.320 --> 00:52:28.600 position:50.00%,middle align:middle size:80.00% line:84.67% Alba. glut23-webvtt-py-cca74e4/tests/samples/no_captions.vtt000066400000000000000000000000061527171777000231600ustar00rootroot00000000000000WEBVTTglut23-webvtt-py-cca74e4/tests/samples/non_padded_timestamps.vtt000066400000000000000000000005041527171777000252100ustar00rootroot00000000000000WEBVTT 6e2b1f0c-0d3a-4c1e-9a7b-2f5d8e1c4a90/12-0 0:0:0.0 --> 0:0:3.540 Good morning everyone. 6e2b1f0c-0d3a-4c1e-9a7b-2f5d8e1c4a90/17-0 0:0:3.540 --> 0:0:7.23 Morning, let's get started. 6e2b1f0c-0d3a-4c1e-9a7b-2f5d8e1c4a90/25-0 0:1:5.2 --> 1:2:7.950 Thanks for joining. glut23-webvtt-py-cca74e4/tests/samples/one_caption.srt000066400000000000000000000000571527171777000231430ustar00rootroot000000000000001 00:00:00,500 --> 00:00:07,000 Caption text #1glut23-webvtt-py-cca74e4/tests/samples/one_caption.vtt000066400000000000000000000000651527171777000231470ustar00rootroot00000000000000WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1glut23-webvtt-py-cca74e4/tests/samples/regions.vtt000066400000000000000000000005311527171777000223150ustar00rootroot00000000000000WEBVTT REGION id:fred width:40% lines:3 regionanchor:0%,100% viewportanchor:10%,90% scroll:up REGION id:bill width:40% lines:3 regionanchor:100%,100% viewportanchor:90%,90% scroll:up 00:00:00.000 --> 00:00:20.000 region:fred align:left Hi, my name is Fred 00:00:02.500 --> 00:00:22.500 region:bill align:right Hi, I’m Bill glut23-webvtt-py-cca74e4/tests/samples/sample.sbv000066400000000000000000000003551527171777000221110ustar00rootroot000000000000000:00:00.378,0:00:11.378 Caption text #1 0:00:11.378,0:00:12.305 Caption text #2 0:00:13.489,0:00:15.489 Caption text #3 (line 1) Caption text #3 (line 2) 0:00:15.489,0:00:16.234 Caption text #4 0:00:17.484,0:00:16.543 Caption text #5glut23-webvtt-py-cca74e4/tests/samples/sample.srt000066400000000000000000000003641527171777000221270ustar00rootroot000000000000001 00:00:00,500 --> 00:00:07,000 Caption text #1 2 00:00:07,000 --> 00:00:11,890 Caption text #2 3 00:00:11,890 --> 00:00:16,320 Caption text #3 4 00:00:16,320 --> 00:00:21,580 Caption text #4 5 00:00:21,580 --> 00:00:23,880 Caption text #5 glut23-webvtt-py-cca74e4/tests/samples/sample.vtt000066400000000000000000000013751527171777000221370ustar00rootroot00000000000000WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:11.890 Caption text #2 00:00:11.890 --> 00:00:16.320 Caption text #3 00:00:16.320 --> 00:00:21.580 Caption text #4 00:00:21.580 --> 00:00:23.880 Caption text #5 00:00:23.880 --> 00:00:27.280 Caption text #6 00:00:27.280 --> 00:00:30.280 Caption text #7 00:00:30.280 --> 00:00:36.510 Caption text #8 00:00:36.510 --> 00:00:38.870 Caption text #9 00:00:38.870 --> 00:00:45.000 Caption text #10 00:00:45.000 --> 00:00:47.000 Caption text #11 00:00:47.000 --> 00:00:50.970 Caption text #12 00:00:50.970 --> 00:00:54.440 Caption text #13 00:00:54.440 --> 00:00:58.600 Caption text #14 00:00:58.600 --> 00:01:01.350 Caption text #15 00:01:01.350 --> 00:01:04.300 Caption text #16glut23-webvtt-py-cca74e4/tests/samples/styles.vtt000066400000000000000000000003151527171777000221720ustar00rootroot00000000000000WEBVTT STYLE ::cue { background-image: linear-gradient(to bottom, dimgray, lightgray); color: papayawhip; } STYLE ::cue(b) { color: peachpuff; } 00:00:00.000 --> 00:00:10.000 - Hello world.glut23-webvtt-py-cca74e4/tests/samples/styles_colours.srt000066400000000000000000000010421527171777000237310ustar00rootroot000000000000001 00:00:07,120 --> 00:00:09,480 Musique douce 2 00:00:09,720 --> 00:00:29,520 --- 3 00:00:32,439 --> 00:00:35,320 Some italic and normal coloured text. By the way, 2 < 3 ! 4 00:00:35,560 --> 00:02:25,240 --- 5 00:02:25,480 --> 00:02:27,440 -Stéphane ? Où on se gare ? 6 00:02:27,680 --> 00:02:29,280 -Euh, là-bas, au chêne. glut23-webvtt-py-cca74e4/tests/samples/styles_colours.vtt000066400000000000000000000020161527171777000237400ustar00rootroot00000000000000WEBVTT STYLE ::cue { font-family: Verdana, Arial, Tiresias; line-height: 125%; } ::cue(.white) { color: #ffffff; } ::cue(.lime) { color: #00ff00; } ::cue(.cyan) { color: #00ffff; } ::cue(.red) { color: #ff0000; } ::cue(.yellow) { color: #ffff00; } ::cue(.magenta) { color: #ff00ff; } ::cue(.blue) { color: #0000ff; } ::cue(.black) { color: #000000; } ::cue(.bg_black) { background: rgba(0, 0, 0, 0.76); } sub0 00:00:07.120 --> 00:00:09.480 line:-1 Musique douce sub1 00:00:09.720 --> 00:00:29.520 align:left line:-1 --- sub2 00:00:32.439 --> 00:00:35.320 line:-1 Some italic and normal coloured text. By the way, 2 < 3 ! sub3 00:00:35.560 --> 00:02:25.240 align:left line:-1 --- sub4 00:02:25.480 --> 00:02:27.440 line:-1 -Stéphane ? Où on se gare ? sub5 00:02:27.680 --> 00:02:29.280 align:left line:-1 -Euh, là-bas, au chêne. glut23-webvtt-py-cca74e4/tests/samples/styles_with_comments.vtt000066400000000000000000000005271527171777000251370ustar00rootroot00000000000000WEBVTT NOTE Sample of comments with styles STYLE ::cue { background-image: linear-gradient(to bottom, dimgray, lightgray); color: papayawhip; } NOTE This is the second block of styles NOTE Multiline comment for the same second block of styles STYLE ::cue(b) { color: peachpuff; } 00:00:00.000 --> 00:00:10.000 - Hello world.glut23-webvtt-py-cca74e4/tests/samples/two_captions.sbv000066400000000000000000000001621527171777000233350ustar00rootroot000000000000000:00:00.378,0:00:11.378 Caption text #1 0:00:11.378,0:00:12.305 Caption text #2 (line 1) Caption text #2 (line 2)glut23-webvtt-py-cca74e4/tests/samples/using_identifiers.vtt000066400000000000000000000004611527171777000243630ustar00rootroot00000000000000WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 second caption 00:00:07.000 --> 00:00:11.890 Caption text #2 00:00:11.890 --> 00:00:16.320 Caption text #3 4 00:00:16.320 --> 00:00:21.580 Caption text #4 00:00:21.580 --> 00:00:23.880 Caption text #5 00:00:23.880 --> 00:00:27.280 Caption text #6glut23-webvtt-py-cca74e4/tests/samples/youtube_dl.vtt000066400000000000000000000012111527171777000230160ustar00rootroot00000000000000WEBVTT Kind: captions Language: en Style: ::cue(c.colorCCCCCC) { color: rgb(204,204,204); } ::cue(c.colorE5E5E5) { color: rgb(229,229,229); } ## 00:04:46.070 --> 00:04:46.470 align:start position:0% yeah 00:04:46.470 --> 00:05:04.080 align:start position:0% yeah what 00:05:04.080 --> 00:05:05.069 align:start position:0% this<00:05:04.199> will<00:05:04.379> happen<00:05:04.620> is<00:05:04.860> I'm<00:05:05.069> telling 00:05:05.069 --> 00:05:05.400 align:start position:0% this will happen is I'm telling glut23-webvtt-py-cca74e4/tests/test_cli.py000066400000000000000000001052551527171777000206350ustar00rootroot00000000000000import unittest import errno import contextlib import importlib import io import tempfile import os import pathlib import runpy import sys import textwrap from unittest import mock import webvtt from webvtt.cli import main class CLITestCase(unittest.TestCase): def test_cli(self): vtt_file = ( pathlib.Path(__file__).resolve().parent / 'samples' / 'sample.vtt' ) with tempfile.TemporaryDirectory() as temp_dir: main(['segment', str(vtt_file.resolve()), '-o', temp_dir]) _, dirs, files = next(os.walk(temp_dir)) self.assertEqual(len(dirs), 0) self.assertEqual(len(files), 8) for expected_file in ('prog_index.m3u8', 'fileSequence0.webvtt', 'fileSequence1.webvtt', 'fileSequence2.webvtt', 'fileSequence3.webvtt', 'fileSequence4.webvtt', 'fileSequence5.webvtt', 'fileSequence6.webvtt', ): self.assertIn(expected_file, files) self.assertEqual( (pathlib.Path(temp_dir) / 'prog_index.m3u8').read_text(), textwrap.dedent( ''' #EXTM3U #EXT-X-TARGETDURATION:10 #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXTINF:10.00000, fileSequence0.webvtt #EXTINF:10.00000, fileSequence1.webvtt #EXTINF:10.00000, fileSequence2.webvtt #EXTINF:10.00000, fileSequence3.webvtt #EXTINF:10.00000, fileSequence4.webvtt #EXTINF:10.00000, fileSequence5.webvtt #EXTINF:10.00000, fileSequence6.webvtt #EXT-X-ENDLIST ''' ).lstrip()) self.assertEqual( (pathlib.Path(temp_dir) / 'fileSequence0.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:11.890 Caption text #2 ''' ).lstrip()) self.assertEqual( (pathlib.Path(temp_dir) / 'fileSequence1.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:07.000 --> 00:00:11.890 Caption text #2 00:00:11.890 --> 00:00:16.320 Caption text #3 00:00:16.320 --> 00:00:21.580 Caption text #4 ''' ).lstrip()) self.assertEqual( (pathlib.Path(temp_dir) / 'fileSequence2.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:16.320 --> 00:00:21.580 Caption text #4 00:00:21.580 --> 00:00:23.880 Caption text #5 00:00:23.880 --> 00:00:27.280 Caption text #6 00:00:27.280 --> 00:00:30.280 Caption text #7 ''' ).lstrip()) self.assertEqual( (pathlib.Path(temp_dir) / 'fileSequence3.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:27.280 --> 00:00:30.280 Caption text #7 00:00:30.280 --> 00:00:36.510 Caption text #8 00:00:36.510 --> 00:00:38.870 Caption text #9 00:00:38.870 --> 00:00:45.000 Caption text #10 ''' ).lstrip()) self.assertEqual( (pathlib.Path(temp_dir) / 'fileSequence4.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:38.870 --> 00:00:45.000 Caption text #10 00:00:45.000 --> 00:00:47.000 Caption text #11 00:00:47.000 --> 00:00:50.970 Caption text #12 ''' ).lstrip()) self.assertEqual( (pathlib.Path(temp_dir) / 'fileSequence5.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:47.000 --> 00:00:50.970 Caption text #12 00:00:50.970 --> 00:00:54.440 Caption text #13 00:00:54.440 --> 00:00:58.600 Caption text #14 00:00:58.600 --> 00:01:01.350 Caption text #15 ''' ).lstrip()) self.assertEqual( (pathlib.Path(temp_dir) / 'fileSequence6.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:58.600 --> 00:01:01.350 Caption text #15 00:01:01.350 --> 00:01:04.300 Caption text #16 ''' ).lstrip()) def test_cli_requires_output(self): vtt_file = ( pathlib.Path(__file__).resolve().parent / 'samples' / 'sample.vtt' ) with contextlib.redirect_stderr(io.StringIO()) as stderr: with self.assertRaises(SystemExit) as cm: main(['segment', str(vtt_file)]) self.assertEqual(cm.exception.code, 2) self.assertIn( 'the following arguments are required: -o/--output', stderr.getvalue() ) def test_cli_segment_duration_and_mpegts(self): vtt_file = str( pathlib.Path(__file__).resolve().parent / 'samples' / 'one_caption.vtt' ) for mpegts in ('0', '8589934591'): with self.subTest(mpegts=mpegts): with tempfile.TemporaryDirectory() as temp_dir: main(['segment', vtt_file, '-o', temp_dir, '-d', '5', '-m', mpegts]) output = pathlib.Path(temp_dir) self.assertIn( '#EXT-X-TARGETDURATION:5', (output / 'prog_index.m3u8').read_text() ) self.assertIn( f'X-TIMESTAMP-MAP=MPEGTS:{mpegts},', (output / 'fileSequence0.webvtt').read_text() ) def test_cli_segment_with_duration(self): vtt_file = str( pathlib.Path(__file__).resolve().parent / 'samples' / 'sample.vtt' ) for duration in ('00:01:25.500', '01:25.500', '85.5'): with self.subTest(duration=duration): with tempfile.TemporaryDirectory() as temp_dir: main(['segment', vtt_file, '-o', temp_dir, '--duration', duration]) manifest = ( pathlib.Path(temp_dir) / 'prog_index.m3u8' ).read_text() self.assertEqual( [line for line in manifest.splitlines() if line.startswith('#EXTINF:')], ['#EXTINF:10.00000,'] * 8 + ['#EXTINF:5.50000,'] ) self.assertEqual(len(os.listdir(temp_dir)), 10) def test_cli_segment_caption_after_duration(self): vtt_file = str( pathlib.Path(__file__).resolve().parent / 'samples' / 'sample.vtt' ) with tempfile.TemporaryDirectory() as temp_dir: output = os.path.join(temp_dir, 'output') self.assert_cli_error( ['segment', vtt_file, '-o', output, '--duration', '30'], f'{vtt_file}: Caption at 00:00:30.280 does not start before ' 'the end of the media at 00:00:30.000' ) self.assertFalse(os.path.exists(output)) def test_cli_segment_invalid_arguments(self): vtt_file = str( pathlib.Path(__file__).resolve().parent / 'samples' / 'sample.vtt' ) for argv, expected_message in ( (['-d', '0'], "argument -d/--target-duration: invalid " "duration: '0'"), (['-d', '-5'], "argument -d/--target-duration: invalid " "duration: '-5'"), (['-d', '2.5'], "argument -d/--target-duration: invalid " "duration: '2.5'"), (['-m', '-1'], "argument -m/--mpegts: invalid MPEG-TS " "value: '-1'"), (['-m', '8589934592'], "argument -m/--mpegts: invalid " "MPEG-TS value: '8589934592'"), (['-m', 'x'], "argument -m/--mpegts: invalid MPEG-TS " "value: 'x'"), (['--duration', '0'], "argument --duration: invalid " "duration: '0'"), (['--duration', '-5'], "argument --duration: invalid " "duration: '-5'"), (['--duration', 'x'], "argument --duration: invalid " "duration: 'x'")): with self.subTest(argv=argv): with tempfile.TemporaryDirectory() as temp_dir: output = os.path.join(temp_dir, 'output') with contextlib.redirect_stderr(io.StringIO()) as stderr: with self.assertRaises(SystemExit) as cm: main(['segment', vtt_file, '-o', output, *argv]) self.assertEqual(cm.exception.code, 2) self.assertIn(expected_message, stderr.getvalue()) self.assertFalse(os.path.exists(output)) def assert_cli_error(self, argv, expected_message): with contextlib.redirect_stderr(io.StringIO()) as stderr: with self.assertRaises(SystemExit) as cm: main(argv) self.assertEqual(cm.exception.code, 1) self.assertEqual( stderr.getvalue(), f'webvtt: error: {expected_message}\n' ) def test_cli_missing_file(self): with tempfile.TemporaryDirectory() as temp_dir: vtt_file = os.path.join(temp_dir, 'missing.vtt') self.assert_cli_error( ['segment', vtt_file, '-o', temp_dir], f'{vtt_file}: {os.strerror(errno.ENOENT)}' ) def test_cli_invalid_file(self): vtt_file = str( pathlib.Path(__file__).resolve().parent / 'samples' / 'invalid.vtt' ) with tempfile.TemporaryDirectory() as temp_dir: self.assert_cli_error( ['segment', vtt_file, '-o', temp_dir], f'{vtt_file}: not a valid WebVTT file' ) def test_cli_file_not_utf8(self): with tempfile.TemporaryDirectory() as temp_dir: vtt_file = os.path.join(temp_dir, 'captions.vtt') pathlib.Path(vtt_file).write_bytes(b'WEBVTT\n\n\xff\xfe\xfd') self.assert_cli_error( ['segment', vtt_file, '-o', temp_dir], f'{vtt_file}: cannot be decoded as utf-8' ) def test_cli_output_is_a_file(self): vtt_file = str( pathlib.Path(__file__).resolve().parent / 'samples' / 'sample.vtt' ) with tempfile.TemporaryDirectory() as temp_dir: output = os.path.join(temp_dir, 'output') pathlib.Path(output).touch() # the message of the error depends on the platform with self.assertRaises(FileExistsError) as cm: os.makedirs(output, exist_ok=True) self.assert_cli_error( ['segment', vtt_file, '-o', output], f'{output}: {cm.exception.strerror}' ) def test_cli_segment_into_folder_with_earlier_segments(self): vtt_file = str( pathlib.Path(__file__).resolve().parent / 'samples' / 'sample.vtt' ) with tempfile.TemporaryDirectory() as temp_dir: main(['segment', vtt_file, '-o', temp_dir]) earlier_files = sorted(os.listdir(temp_dir)) self.assert_cli_error( ['segment', vtt_file, '-o', temp_dir], f'{temp_dir}: has the files of an earlier segmentation, ' 'such as fileSequence0.webvtt; use --overwrite to replace ' 'them' ) self.assertEqual(sorted(os.listdir(temp_dir)), earlier_files) main(['segment', vtt_file, '-o', temp_dir, '-d', '20', '--overwrite']) self.assertEqual( sorted(os.listdir(temp_dir)), [f'fileSequence{index}.webvtt' for index in range(4)] + ['prog_index.m3u8'] ) self.assertIn( '#EXT-X-TARGETDURATION:20', (pathlib.Path(temp_dir) / 'prog_index.m3u8').read_text() ) def test_cli_segment_output_cannot_be_listed(self): vtt_file = str( pathlib.Path(__file__).resolve().parent / 'samples' / 'sample.vtt' ) with tempfile.TemporaryDirectory() as temp_dir: error = PermissionError( errno.EACCES, os.strerror(errno.EACCES), temp_dir ) with mock.patch.object(pathlib.Path, 'iterdir', side_effect=error): self.assert_cli_error( ['segment', vtt_file, '-o', temp_dir], f'{temp_dir}: {os.strerror(errno.EACCES)}' ) def test_cli_version(self): with contextlib.redirect_stdout(io.StringIO()) as stdout: with self.assertRaises(SystemExit) as cm: main(['--version']) self.assertEqual(cm.exception.code, 0) self.assertEqual(stdout.getvalue(), f'webvtt {webvtt.__version__}\n') def test_cli_as_module(self): vtt_file = str( pathlib.Path(__file__).resolve().parent / 'samples' / 'one_caption.vtt' ) with mock.patch.object(sys, 'argv', ['webvtt', 'shift', vtt_file, '1']), \ contextlib.redirect_stdout(io.StringIO()) as stdout: with self.assertRaises(SystemExit) as cm: runpy.run_module('webvtt', run_name='__main__') self.assertIsNone(cm.exception.code) self.assertIn('00:00:01.500 --> 00:00:08.000', stdout.getvalue()) def test_cli_module_import_does_not_run(self): with mock.patch.object(sys, 'argv', ['webvtt', '--version']), \ mock.patch.dict(sys.modules), \ contextlib.redirect_stdout(io.StringIO()) as stdout: # import it again, also when another test or tool imported it sys.modules.pop('webvtt.__main__', None) importlib.import_module('webvtt.__main__') self.assertEqual(stdout.getvalue(), '') def test_cli_requires_command(self): with contextlib.redirect_stderr(io.StringIO()) as stderr: with self.assertRaises(SystemExit) as cm: main([]) self.assertEqual(cm.exception.code, 2) self.assertIn( 'the following arguments are required: command', stderr.getvalue() ) class CLICommandTestCase(unittest.TestCase): CAPTIONS = textwrap.dedent( ''' WEBVTT 00:00:00.500 --> 00:00:01.000 Credits 00:00:01.500 --> 00:00:04.000 Caption #1 00:00:04.000 --> 00:00:07.000 Caption #2 ''' ).lstrip() def setUp(self): self.temp_dir = tempfile.TemporaryDirectory() self.addCleanup(self.temp_dir.cleanup) self.vtt_file = os.path.join(self.temp_dir.name, 'captions.vtt') pathlib.Path(self.vtt_file).write_text(self.CAPTIONS) def run_cli(self, argv): with contextlib.redirect_stdout(io.StringIO()) as stdout: main(argv) return stdout.getvalue() def assert_usage_error(self, argv, expected_message): with contextlib.redirect_stderr(io.StringIO()) as stderr: with self.assertRaises(SystemExit) as cm: main(argv) self.assertEqual(cm.exception.code, 2) self.assertIn(expected_message, stderr.getvalue()) class CLIShiftTestCase(CLICommandTestCase): def test_shift_to_stdout(self): self.assertEqual( self.run_cli(['shift', self.vtt_file, '1.25']), textwrap.dedent( ''' WEBVTT 00:00:01.750 --> 00:00:02.250 Credits 00:00:02.750 --> 00:00:05.250 Caption #1 00:00:05.250 --> 00:00:08.250 Caption #2 ''' ).lstrip() ) self.assertEqual( pathlib.Path(self.vtt_file).read_text(), self.CAPTIONS ) def test_shift_negative_clamps_to_zero(self): self.assertEqual( self.run_cli(['shift', self.vtt_file, '-2']), textwrap.dedent( ''' WEBVTT 00:00:00.000 --> 00:00:00.000 Credits 00:00:00.000 --> 00:00:02.000 Caption #1 00:00:02.000 --> 00:00:05.000 Caption #2 ''' ).lstrip() ) def test_shift_from_start(self): self.assertEqual( self.run_cli( ['shift', self.vtt_file, '-0.5', '--start', '00:00:01.500'] ), textwrap.dedent( ''' WEBVTT 00:00:00.500 --> 00:00:01.000 Credits 00:00:01.000 --> 00:00:03.500 Caption #1 00:00:03.500 --> 00:00:06.500 Caption #2 ''' ).lstrip() ) def test_shift_rounds_offset_as_written(self): for offset, expected in (('1.0005', '00:00:01.501 --> 00:00:02.001'), ('-0.0005', '00:00:00.499 --> 00:00:00.999'), ('0.0025', '00:00:00.503 --> 00:00:01.003'), ('0.0004999', '00:00:00.500 --> 00:00:01.000'), ('2e-3', '00:00:00.502 --> 00:00:01.002'), ('259.0395', '00:04:19.540 --> 00:04:20.040')): with self.subTest(offset=offset): self.assertIn( expected, self.run_cli(['shift', self.vtt_file, offset]) ) def test_shift_to_output_file(self): output = os.path.join(self.temp_dir.name, 'shifted.vtt') self.assertEqual( self.run_cli(['shift', self.vtt_file, '3', '-o', output]), '' ) self.assertEqual( pathlib.Path(output).read_text(), textwrap.dedent( ''' WEBVTT 00:00:03.500 --> 00:00:04.000 Credits 00:00:04.500 --> 00:00:07.000 Caption #1 00:00:07.000 --> 00:00:10.000 Caption #2 ''' ).lstrip() ) self.assertEqual( pathlib.Path(self.vtt_file).read_text(), self.CAPTIONS ) def test_shift_to_output_file_with_other_extension(self): output = os.path.join(self.temp_dir.name, 'shifted.txt') self.run_cli(['shift', self.vtt_file, '3', '-o', output]) self.assertTrue(os.path.exists(output)) self.assertFalse(os.path.exists(f'{output}.vtt')) def test_shift_in_place(self): self.run_cli(['shift', self.vtt_file, '-s', '00:00:04.000', '1', '-o', self.vtt_file]) self.assertEqual( pathlib.Path(self.vtt_file).read_text(), self.CAPTIONS.replace( '00:00:04.000 --> 00:00:07.000', '00:00:05.000 --> 00:00:08.000' ) ) def test_shift_keeps_bom(self): pathlib.Path(self.vtt_file).write_bytes( b'\xef\xbb\xbf' + self.CAPTIONS.encode('utf-8') ) self.run_cli(['shift', self.vtt_file, '1', '-o', self.vtt_file]) # lines end with the line separator of the platform self.assertTrue( pathlib.Path(self.vtt_file).read_bytes().startswith( b'\xef\xbb\xbfWEBVTT' + os.linesep.encode() * 2 + b'00:00:01.500 --> 00:00:02.000' ) ) def test_shift_to_stdout_in_utf8(self): pathlib.Path(self.vtt_file).write_text( self.CAPTIONS.replace('Caption #1', 'Subtítulo #1'), encoding='utf-8' ) stdout = io.TextIOWrapper(io.BytesIO(), encoding='ascii') with contextlib.redirect_stdout(stdout): main(['shift', self.vtt_file, '0']) self.assertEqual( stdout.buffer.getvalue().decode('utf-8'), self.CAPTIONS.replace('Caption #1', 'Subtítulo #1') ) def test_shift_to_closed_pipe(self): class ClosedPipe(io.StringIO): def __init__(self, fd): super().__init__() self.fd = fd def write(self, s): raise BrokenPipeError def fileno(self): return self.fd with tempfile.TemporaryFile() as f: with contextlib.redirect_stdout(ClosedPipe(f.fileno())), \ contextlib.redirect_stderr(io.StringIO()) as stderr: with self.assertRaises(SystemExit) as cm: main(['shift', self.vtt_file, '1']) self.assertEqual(cm.exception.code, 1) self.assertEqual(stderr.getvalue(), '') def test_shift_invalid_offset(self): for value in ('abc', 'nan', 'sNaN', 'inf', '1e400', ''): with self.subTest(value=value): self.assert_usage_error( ['shift', self.vtt_file, value], f'argument OFFSET: invalid offset: {value!r}' ) def test_shift_invalid_start(self): self.assert_usage_error( ['shift', self.vtt_file, '1', '--start', '1:2'], "argument -s/--start: invalid timestamp: '1:2'" ) def test_shift_requires_offset(self): self.assert_usage_error( ['shift', self.vtt_file], 'the following arguments are required: OFFSET' ) def test_shift_missing_file(self): vtt_file = os.path.join(self.temp_dir.name, 'missing.vtt') with contextlib.redirect_stderr(io.StringIO()) as stderr: with self.assertRaises(SystemExit) as cm: main(['shift', vtt_file, '1']) self.assertEqual(cm.exception.code, 1) self.assertEqual( stderr.getvalue(), f'webvtt: error: {vtt_file}: {os.strerror(errno.ENOENT)}\n' ) def test_after_largest_timestamp(self): for argv in (['shift', self.vtt_file, '1e300', '-o', self.vtt_file], ['sync', self.vtt_file, '--fps', '1e300', '1', '-o', self.vtt_file]): with self.subTest(command=argv[0]): with contextlib.redirect_stderr(io.StringIO()) as stderr: with self.assertRaises(SystemExit) as cm: main(argv) self.assertEqual(cm.exception.code, 1) self.assertEqual( stderr.getvalue(), f'webvtt: error: {self.vtt_file}: Time after the ' 'largest timestamp 9999999999:59:59.999\n' ) self.assertEqual( pathlib.Path(self.vtt_file).read_text(), self.CAPTIONS ) def test_shift_invalid_file(self): vtt_file = str( pathlib.Path(__file__).resolve().parent / 'samples' / 'invalid.vtt' ) with contextlib.redirect_stderr(io.StringIO()) as stderr: with self.assertRaises(SystemExit) as cm: main(['shift', vtt_file, '1']) self.assertEqual(cm.exception.code, 1) self.assertEqual( stderr.getvalue(), f'webvtt: error: {vtt_file}: not a valid WebVTT file\n' ) class CLIStdinTestCase(CLICommandTestCase): def run_cli_stdin(self, argv, data, stdin_encoding='ascii'): stdin = io.TextIOWrapper(io.BytesIO(data), encoding=stdin_encoding) with mock.patch.object(sys, 'stdin', stdin): return self.run_cli(argv) def assert_cli_stdin_error(self, argv, data, expected_message): with contextlib.redirect_stderr(io.StringIO()) as stderr: with self.assertRaises(SystemExit) as cm: self.run_cli_stdin(argv, data) self.assertEqual(cm.exception.code, 1) self.assertEqual(stderr.getvalue(), f'webvtt: error: {expected_message}\n') def test_shift_and_sync_from_stdin(self): for argv in (['shift', '{}', '1.25'], ['shift', '{}', '1', '--start', '00:00:01.500'], ['sync', '{}', '--fps', '25', '24'], ['sync', '{}', '-p', '1', '2', '-p', '4', '6']): with self.subTest(argv=argv): self.assertEqual( self.run_cli_stdin( [arg.format('-') for arg in argv], self.CAPTIONS.encode('utf-8') ), self.run_cli([arg.format(self.vtt_file) for arg in argv]) ) def test_segment_from_stdin(self): from_file = os.path.join(self.temp_dir.name, 'from_file') from_stdin = os.path.join(self.temp_dir.name, 'from_stdin') main(['segment', self.vtt_file, '-o', from_file, '-d', '2']) self.run_cli_stdin(['segment', '-', '-o', from_stdin, '-d', '2'], self.CAPTIONS.encode('utf-8')) self.assertEqual(sorted(os.listdir(from_stdin)), sorted(os.listdir(from_file))) for name in os.listdir(from_file): self.assertEqual( pathlib.Path(from_stdin, name).read_text(), pathlib.Path(from_file, name).read_text() ) def test_segment_from_stdin_invalid_content(self): output = os.path.join(self.temp_dir.name, 'output') self.assert_cli_stdin_error(['segment', '-', '-o', output], b'not WebVTT', 'standard input: not a valid WebVTT file') self.assertFalse(os.path.exists(output)) def test_stdin_is_read_as_utf8(self): captions = self.CAPTIONS.replace('Caption #1', 'Subtítulo #1') for data in (captions.encode('utf-8'), b'\xef\xbb\xbf' + captions.encode('utf-8'), captions.replace('\n', '\r\n').encode('utf-8')): with self.subTest(data=data[:8]): self.assertEqual( self.run_cli_stdin(['shift', '-', '0'], data), captions ) def test_stdin_without_buffer(self): with mock.patch.object(sys, 'stdin', io.StringIO(self.CAPTIONS)): self.assertEqual(self.run_cli(['shift', '-', '0']), self.CAPTIONS) def test_stdin_closed(self): with mock.patch.object(sys, 'stdin', None), \ contextlib.redirect_stderr(io.StringIO()) as stderr: with self.assertRaises(SystemExit) as cm: main(['shift', '-', '1']) self.assertEqual(cm.exception.code, 1) self.assertEqual( stderr.getvalue(), f'webvtt: error: standard input: {os.strerror(errno.EBADF)}\n' ) def test_stdin_to_output_file(self): output = os.path.join(self.temp_dir.name, 'shifted') self.assertEqual( self.run_cli_stdin(['shift', '-', '0', '-o', output], self.CAPTIONS.encode('utf-8')), '' ) self.assertEqual(pathlib.Path(f'{output}.vtt').read_text(), self.CAPTIONS) def test_stdin_to_output_directory(self): self.assert_cli_stdin_error( ['shift', '-', '0', '-o', self.temp_dir.name], self.CAPTIONS.encode('utf-8'), f'{self.temp_dir.name}: is a directory, give a file name to ' 'save the captions read from standard input' ) self.assertEqual(os.listdir(self.temp_dir.name), ['captions.vtt']) def test_stdin_invalid_content(self): for data, message in ( (b'not WebVTT', 'not a valid WebVTT file'), (b'WEBVTT\n\n\xff\n', 'cannot be decoded as utf-8')): with self.subTest(data=data): self.assert_cli_stdin_error(['sync', '-', '--fps', '25', '24'], data, f'standard input: {message}') class CLISyncTestCase(CLICommandTestCase): def test_sync_points_to_stdout(self): self.assertEqual( self.run_cli(['sync', self.vtt_file, '-p', '00:00:00.500', '1', '--point', '00:00:07.000', '13']), textwrap.dedent( ''' WEBVTT 00:00:01.000 --> 00:00:01.923 Credits 00:00:02.846 --> 00:00:07.462 Caption #1 00:00:07.462 --> 00:00:13.000 Caption #2 ''' ).lstrip() ) self.assertEqual( pathlib.Path(self.vtt_file).read_text(), self.CAPTIONS ) def test_sync_fps(self): self.assertEqual( self.run_cli(['sync', self.vtt_file, '--fps', '25', '24']), textwrap.dedent( ''' WEBVTT 00:00:00.521 --> 00:00:01.042 Credits 00:00:01.563 --> 00:00:04.167 Caption #1 00:00:04.167 --> 00:00:07.292 Caption #2 ''' ).lstrip() ) def test_sync_fps_ratio(self): self.assertIn( '00:00:04.171 --> 00:00:07.299\nCaption #2', self.run_cli(['sync', self.vtt_file, '--fps', '25', '24000/1001']) ) def test_sync_to_output_file(self): output = os.path.join(self.temp_dir.name, 'synced.vtt') self.assertEqual( self.run_cli(['sync', self.vtt_file, '-p', '0', '0', '-p', '4', '8', '-o', output]), '' ) self.assertIn( '00:00:03.000 --> 00:00:08.000\nCaption #1', pathlib.Path(output).read_text() ) self.assertEqual( pathlib.Path(self.vtt_file).read_text(), self.CAPTIONS ) def test_sync_in_place(self): self.run_cli(['sync', self.vtt_file, '--fps', '24', '24', '-o', self.vtt_file]) self.assertEqual( pathlib.Path(self.vtt_file).read_text(), self.CAPTIONS ) def test_sync_invalid_arguments(self): for argv, expected_message in ( (['-p', '1', '2'], 'At least two sync points are required'), (['-p', '1', '2', '-p', '1', '3'], 'Two sync points have the same time 00:00:01.000'), (['-p', '1', '5', '-p', '2', '4'], 'Sync points must keep the order of the captions'), (['-p', '1', '2', '-p', '-5', '3'], "argument -p/--point: invalid timestamp: '-5'"), (['-p', '1', '2', '-p', 'x', '3'], "argument -p/--point: invalid timestamp: 'x'"), (['-p', '1', '2', '-p', 'nan', '3'], "argument -p/--point: invalid timestamp: 'nan'"), (['-p', '1', '2', '-p', '3', '1e300'], 'Time after the largest timestamp 9999999999:59:59.999'), (['-p', '1'], 'argument -p/--point: expected 2 arguments'), (['--fps', '0', '25'], "argument --fps: invalid frame rate: '0'"), (['--fps', '25', 'inf'], "argument --fps: invalid frame rate: 'inf'"), (['--fps', '25', '1/0'], "argument --fps: invalid frame rate: '1/0'"), (['--fps', '25', '-24'], "argument --fps: invalid frame rate: '-24'"), (['-p', '1', '2', '-p', '3', '4', '--fps', '25', '24'], 'argument --fps: not allowed with argument -p/--point'), ([], 'one of the arguments -p/--point --fps is required')): with self.subTest(argv=argv): self.assert_usage_error( ['sync', self.vtt_file] + argv, expected_message ) def test_sync_missing_file(self): vtt_file = os.path.join(self.temp_dir.name, 'missing.vtt') with contextlib.redirect_stderr(io.StringIO()) as stderr: with self.assertRaises(SystemExit) as cm: main(['sync', vtt_file, '--fps', '25', '24']) self.assertEqual(cm.exception.code, 1) self.assertEqual( stderr.getvalue(), f'webvtt: error: {vtt_file}: {os.strerror(errno.ENOENT)}\n' ) glut23-webvtt-py-cca74e4/tests/test_errors.py000066400000000000000000000046051527171777000213770ustar00rootroot00000000000000import io import unittest import webvtt from webvtt.errors import ( WebVTTError, MalformedFileError, MalformedCaptionError, MalformedRegionError, MissingFilenameError ) class TestErrors(unittest.TestCase): def test_errors_are_webvtt_errors(self): for error in ( MalformedFileError, MalformedCaptionError, MalformedRegionError, MissingFilenameError ): with self.subTest(error=error): self.assertTrue(issubclass(error, WebVTTError)) def test_malformed_errors_are_value_errors(self): for error in (MalformedFileError, MalformedCaptionError, MalformedRegionError): with self.subTest(error=error): self.assertTrue(issubclass(error, ValueError)) def test_region_error_is_a_caption_error(self): self.assertTrue( issubclass(MalformedRegionError, MalformedCaptionError) ) def test_catch_region_error_as_caption_error(self): for call in ( lambda: webvtt.from_string( 'WEBVTT\n\nREGION\nwidth:abc\n', strict=True ), lambda: webvtt.Region(width='abc'), lambda: webvtt.Region.from_string('id:fred --> x')): with self.assertRaises(MalformedRegionError) as cm: call() self.assertIsInstance(cm.exception, MalformedCaptionError) def test_cue_setting_error_is_not_a_region_error(self): with self.assertRaises(MalformedCaptionError) as cm: webvtt.CueSettings(region='not valid') self.assertNotIsInstance(cm.exception, MalformedRegionError) def test_missing_filename_is_not_a_value_error(self): self.assertFalse(issubclass(MissingFilenameError, ValueError)) def test_catch_webvtt_error(self): with self.assertRaises(WebVTTError): webvtt.from_buffer(io.StringIO('not WebVTT')) with self.assertRaises(WebVTTError): webvtt.Caption('invalid', '00:00:01.000') with self.assertRaises(WebVTTError): webvtt.WebVTT().save() def test_catch_value_error(self): with self.assertRaises(ValueError): webvtt.from_buffer(io.StringIO('not WebVTT')) with self.assertRaises(ValueError): webvtt.Caption('invalid', '00:00:01.000') glut23-webvtt-py-cca74e4/tests/test_models.py000066400000000000000000001464451527171777000213570ustar00rootroot00000000000000import datetime import re import time import unittest from webvtt.models import Timestamp, Caption, CueSettings, Region, Style from webvtt.errors import MalformedCaptionError, MalformedRegionError class TestTimestamp(unittest.TestCase): def test_instantiation(self): timestamp = Timestamp( hours=1, minutes=12, seconds=23, milliseconds=500 ) self.assertEqual(timestamp.hours, 1) self.assertEqual(timestamp.minutes, 12) self.assertEqual(timestamp.seconds, 23) self.assertEqual(timestamp.milliseconds, 500) def test_from_string(self): timestamp = Timestamp.from_string('01:24:11.670') self.assertEqual(timestamp.hours, 1) self.assertEqual(timestamp.minutes, 24) self.assertEqual(timestamp.seconds, 11) self.assertEqual(timestamp.milliseconds, 670) def test_from_string_single_digits(self): timestamp = Timestamp.from_string('1:2:7.670') self.assertEqual(timestamp.hours, 1) self.assertEqual(timestamp.minutes, 2) self.assertEqual(timestamp.seconds, 7) self.assertEqual(timestamp.milliseconds, 670) def test_from_string_short_fraction(self): timestamp = Timestamp.from_string('0:1:5.2') self.assertEqual(timestamp.hours, 0) self.assertEqual(timestamp.minutes, 1) self.assertEqual(timestamp.seconds, 5) self.assertEqual(timestamp.milliseconds, 200) self.assertEqual(str(timestamp), '00:01:05.200') self.assertEqual(Timestamp.from_string('00:12.54').milliseconds, 540) def test_from_string_missing_hours(self): timestamp = Timestamp.from_string('24:11.670') self.assertEqual(timestamp.hours, 0) self.assertEqual(timestamp.minutes, 24) self.assertEqual(timestamp.seconds, 11) self.assertEqual(timestamp.milliseconds, 670) def test_from_string_more_than_two_digits_hours(self): timestamp = Timestamp.from_string('100:24:11.670') self.assertEqual(timestamp.hours, 100) self.assertEqual(timestamp.minutes, 24) self.assertEqual(timestamp.seconds, 11) self.assertEqual(timestamp.milliseconds, 670) self.assertEqual(str(timestamp), '100:24:11.670') def test_from_string_ten_digits_hours(self): timestamp = Timestamp.from_string('9999999999:00:00.000') self.assertEqual(timestamp.hours, 9999999999) self.assertEqual(str(timestamp), '9999999999:00:00.000') def test_from_string_too_many_digits_hours(self): for value in ('10000000000:00:00.000', '1' * 5000 + ':00:00.000'): with self.subTest(digits=len(value) - 10): with self.assertRaises(MalformedCaptionError): Timestamp.from_string(value) def test_from_string_wrong_minutes(self): with self.assertRaises(MalformedCaptionError): Timestamp.from_string('01:76:11.670') def test_from_string_wrong_seconds(self): with self.assertRaises(MalformedCaptionError): Timestamp.from_string('01:24:87.670') def test_from_string_non_ascii_digits(self): for value in ('\u0660\u0660:\u0660\u0661.\u0665\u0660\u0660', '00:0\uff11.500'): with self.subTest(value=value): with self.assertRaises(MalformedCaptionError): Timestamp.from_string(value) def test_from_string_wrong_type(self): with self.assertRaises(MalformedCaptionError): Timestamp.from_string(1234) def test_from_string_wrong_value(self): with self.assertRaises(MalformedCaptionError): Timestamp.from_string('01:24:11:670') def test_from_string_trailing_characters(self): for value in ('01:24:11.6705', '01:24:11.670abc', '01:24:11.670 '): with self.assertRaises(MalformedCaptionError): Timestamp.from_string(value) def test_to_tuple(self): self.assertEqual( Timestamp( hours=1, minutes=12, seconds=23, milliseconds=500 ).to_tuple(), (1, 12, 23, 500) ) def test_equality(self): self.assertTrue( Timestamp( hours=1, minutes=12, seconds=23, milliseconds=500 ) == Timestamp.from_string('01:12:23.500') ) def test_not_equality(self): self.assertTrue( Timestamp( hours=1, minutes=12, seconds=23, milliseconds=500 ) != Timestamp.from_string('01:12:23.600') ) def test_greater_than(self): self.assertTrue( Timestamp( hours=1, minutes=12, seconds=23, milliseconds=600 ) > Timestamp.from_string('01:12:23.500') ) def test_less_than(self): self.assertTrue( Timestamp( hours=1, minutes=12, seconds=23, milliseconds=500 ) < Timestamp.from_string('01:12:23.600') ) def test_greater_or_equal_than(self): self.assertTrue( Timestamp( hours=1, minutes=12, seconds=23, milliseconds=500 ) >= Timestamp.from_string('01:12:23.500') ) self.assertTrue( Timestamp( hours=1, minutes=12, seconds=23, milliseconds=600 ) >= Timestamp.from_string('01:12:23.500') ) def test_less_or_equal_than(self): self.assertTrue( Timestamp( hours=1, minutes=12, seconds=23, milliseconds=500 ) <= Timestamp.from_string('01:12:23.500') ) self.assertTrue( Timestamp( hours=1, minutes=12, seconds=23, milliseconds=500 ) <= Timestamp.from_string('01:12:23.600') ) def test_equality_other_types(self): timestamp = Timestamp(seconds=1) for other in (None, '00:00:01.000', 1, (0, 0, 1, 0)): with self.subTest(other=other): self.assertFalse(timestamp == other) self.assertTrue(timestamp != other) self.assertNotIn(None, [timestamp]) def test_ordering_other_types(self): timestamp = Timestamp(seconds=1) for other in (None, '00:00:01.000', 1): with self.subTest(other=other): with self.assertRaises(TypeError): timestamp < other with self.assertRaises(TypeError): timestamp > other with self.assertRaises(TypeError): timestamp <= other with self.assertRaises(TypeError): timestamp >= other with self.assertRaises(TypeError): other < timestamp def test_sort(self): self.assertEqual( sorted([Timestamp(seconds=2), Timestamp(), Timestamp(seconds=1)]), [Timestamp(), Timestamp(seconds=1), Timestamp(seconds=2)] ) def test_repr(self): timestamp = Timestamp( hours=1, minutes=12, seconds=45, milliseconds=320 ) self.assertEqual( repr(timestamp), '' ) def test_from_seconds(self): timestamp = Timestamp.from_seconds(5051.67) self.assertEqual(timestamp.hours, 1) self.assertEqual(timestamp.minutes, 24) self.assertEqual(timestamp.seconds, 11) self.assertEqual(timestamp.milliseconds, 670) self.assertEqual(str(timestamp), '01:24:11.670') def test_from_seconds_int(self): self.assertEqual(str(Timestamp.from_seconds(0)), '00:00:00.000') self.assertEqual(str(Timestamp.from_seconds(65)), '00:01:05.000') def test_from_seconds_over_24_hours(self): self.assertEqual( str(Timestamp.from_seconds(360005.2)), '100:00:05.200' ) def test_from_seconds_rounds_to_milliseconds(self): self.assertEqual(str(Timestamp.from_seconds(0.0004)), '00:00:00.000') self.assertEqual(str(Timestamp.from_seconds(1.2346)), '00:00:01.235') self.assertEqual(str(Timestamp.from_seconds(59.9996)), '00:01:00.000') self.assertEqual( str(Timestamp.from_seconds(3599.9999)), '01:00:00.000' ) def test_from_seconds_rounds_halves_up(self): for value, expected in ((0.0005, '00:00:00.001'), (0.0015, '00:00:00.002'), (0.0025, '00:00:00.003'), (1.0005, '00:00:01.001'), (1.9995, '00:00:02.000'), (0.5075, '00:00:00.508'), (259.0395, '00:04:19.040'), (0.0004999, '00:00:00.000')): with self.subTest(value=value): self.assertEqual(str(Timestamp.from_seconds(value)), expected) def test_from_seconds_largest_timestamp(self): self.assertEqual(str(Timestamp.from_seconds(35999999999999)), '9999999999:59:59.000') def test_from_seconds_after_largest_timestamp(self): for value in (36000000000000, 1e300, 10 ** 400, 10 ** 1000000): with self.subTest(value=type(value).__name__): with self.assertRaises(MalformedCaptionError): Timestamp.from_seconds(value) def test_from_seconds_invalid(self): for value in (-0.001, -1, float('nan'), float('inf'), '12', None, True): with self.subTest(value=value): with self.assertRaises(MalformedCaptionError): Timestamp.from_seconds(value) def test_from_seconds_round_trip(self): for value in ('00:00:00.000', '00:00:00.001', '01:24:11.670', '23:59:59.999', '100:24:11.670'): with self.subTest(value=value): timestamp = Timestamp.from_string(value) self.assertEqual( Timestamp.from_seconds( timestamp.in_milliseconds() / 1000 ), timestamp ) def test_in_seconds(self): self.assertEqual(Timestamp.from_string('01:24:11.999').in_seconds(), 5051 ) def test_in_milliseconds(self): self.assertEqual( Timestamp.from_string('01:24:11.670').in_milliseconds(), 5051670 ) self.assertEqual( Timestamp.from_string('00:00:00.000').in_milliseconds(), 0 ) class TestCaption(unittest.TestCase): def test_instantiation(self): caption = Caption( start='00:00:07.000', end='00:00:11.890', text='Hello test!', identifier='A test caption' ) self.assertEqual(caption.start, '00:00:07.000') self.assertEqual(caption.end, '00:00:11.890') self.assertEqual(caption.text, 'Hello test!') self.assertEqual(caption.identifier, 'A test caption') def test_timestamp_wrong_type(self): with self.assertRaises(MalformedCaptionError): Caption( start=1234, end='00:00:11.890', text='Hello test!', identifier='A test caption' ) def test_identifier_is_optional(self): caption = Caption( start='00:00:07.000', end='00:00:11.890', text='Hello test!', ) self.assertIsNone(caption.identifier) def test_multi_lines(self): caption = Caption( start='00:00:07.000', end='00:00:11.890', text='Hello test!\nThis is the second line', identifier='A test caption' ) self.assertEqual(caption.text, 'Hello test!\nThis is the second line') self.assertListEqual( caption.lines, ['Hello test!', 'This is the second line'] ) def test_multi_lines_accepts_list(self): caption = Caption( start='00:00:07.000', end='00:00:11.890', text=['Hello test!', 'This is the second line'], identifier='A test caption' ) self.assertEqual(caption.text, 'Hello test!\nThis is the second line') self.assertListEqual( caption.lines, ['Hello test!', 'This is the second line'] ) def test_cuetags(self): caption = Caption( start='00:00:07.000', end='00:00:11.890', text=[ 'Hello test!', 'This is the second line' ], identifier='A test caption' ) self.assertEqual(caption.text, 'Hello test!\nThis is the second line') self.assertEqual( caption.raw_text, 'Hello test!\n' 'This is the second line' ) def test_in_seconds(self): caption = Caption( start='00:00:07.000', end='00:00:11.890', text=['Hello test!', 'This is the second line'], identifier='A test caption' ) self.assertEqual(caption.start_in_seconds, 7) self.assertEqual(caption.end_in_seconds, 11) def test_shift(self): caption = Caption('00:00:07.000', '00:00:11.890', 'Hello test!') caption.shift(1.5) self.assertEqual(caption.start, '00:00:08.500') self.assertEqual(caption.end, '00:00:13.390') self.assertEqual(caption.text, 'Hello test!') def test_shift_int(self): caption = Caption('00:00:07.000', '00:00:11.890') caption.shift(3600) self.assertEqual(caption.start, '01:00:07.000') self.assertEqual(caption.end, '01:00:11.890') def test_shift_negative(self): caption = Caption('00:01:07.000', '00:01:11.890') caption.shift(-62.25) self.assertEqual(caption.start, '00:00:04.750') self.assertEqual(caption.end, '00:00:09.640') def test_shift_timedelta(self): caption = Caption('00:00:07.000', '00:00:11.890') caption.shift(datetime.timedelta(minutes=1, milliseconds=10)) self.assertEqual(caption.start, '00:01:07.010') self.assertEqual(caption.end, '00:01:11.900') caption.shift(datetime.timedelta(seconds=-60)) self.assertEqual(caption.start, '00:00:07.010') self.assertEqual(caption.end, '00:00:11.900') def test_shift_rounds_to_milliseconds(self): caption = Caption('00:00:07.000', '00:00:11.890') caption.shift(0.0016) self.assertEqual(caption.start, '00:00:07.002') self.assertEqual(caption.end, '00:00:11.892') caption.shift(datetime.timedelta(microseconds=-1600)) self.assertEqual(caption.start, '00:00:07.000') self.assertEqual(caption.end, '00:00:11.890') def test_shift_rounds_halves_away_from_zero(self): for offset, expected in ((0.0005, '00:00:10.001'), (-0.0005, '00:00:09.999'), (0.0025, '00:00:10.003'), (-0.0025, '00:00:09.997'), (0.0004999, '00:00:10.000'), (0.5075, '00:00:10.508'), (-0.5075, '00:00:09.492'), (259.0395, '00:04:29.040'), (datetime.timedelta(microseconds=500), '00:00:10.001'), (datetime.timedelta(microseconds=-500), '00:00:09.999'), (datetime.timedelta(microseconds=2500), '00:00:10.003'), (datetime.timedelta(microseconds=-2500), '00:00:09.997'), (datetime.timedelta(microseconds=499), '00:00:10.000')): with self.subTest(offset=offset): caption = Caption('00:00:10.000', '00:00:11.000') caption.shift(offset) self.assertEqual(caption.start, expected) def test_shift_and_back(self): caption = Caption('00:00:10.000', '00:00:11.000') for offset in (0.0005, 0.0025, 1.0005, -3.7775): with self.subTest(offset=offset): caption.shift(offset) caption.shift(-offset) self.assertEqual(caption.start, '00:00:10.000') self.assertEqual(caption.end, '00:00:11.000') def test_shift_clamps_start_to_zero(self): caption = Caption('00:00:00.500', '00:00:04.000') caption.shift(-2) self.assertEqual(caption.start, '00:00:00.000') self.assertEqual(caption.end, '00:00:02.000') def test_shift_clamps_start_and_end_to_zero(self): caption = Caption('00:00:00.500', '00:00:04.000') caption.shift(-10) self.assertEqual(caption.start, '00:00:00.000') self.assertEqual(caption.end, '00:00:00.000') def test_shift_invalid_type(self): for value in ('1.5', None, True, [1]): with self.subTest(value=value): caption = Caption('00:00:07.000', '00:00:11.890') with self.assertRaises(TypeError): caption.shift(value) self.assertEqual(caption.start, '00:00:07.000') self.assertEqual(caption.end, '00:00:11.890') def test_shift_to_largest_timestamp(self): caption = Caption('00:00:07.000', '00:00:11.890') caption.shift(datetime.timedelta(milliseconds=35999999999988109)) self.assertEqual(caption.end, '9999999999:59:59.999') def test_shift_after_largest_timestamp(self): for value in (datetime.timedelta(milliseconds=35999999999988110), 1e300, 10 ** 400, 10 ** 1000000): with self.subTest(value=type(value).__name__): caption = Caption('00:00:07.000', '00:00:11.890') with self.assertRaises(ValueError): caption.shift(value) self.assertEqual(caption.start, '00:00:07.000') self.assertEqual(caption.end, '00:00:11.890') def test_shift_before_zero_by_a_huge_offset(self): for value in (-1e300, -10 ** 400, -10 ** 1000000): with self.subTest(value=type(value).__name__): caption = Caption('00:00:07.000', '00:00:11.890') caption.shift(value) self.assertEqual(caption.start, '00:00:00.000') self.assertEqual(caption.end, '00:00:00.000') def test_shift_not_finite(self): for value in (float('nan'), float('inf'), float('-inf')): with self.subTest(value=value): caption = Caption('00:00:07.000', '00:00:11.890') with self.assertRaises(ValueError): caption.shift(value) self.assertEqual(caption.start, '00:00:07.000') self.assertEqual(caption.end, '00:00:11.890') def test_wrong_start_timestamp(self): self.assertRaises( MalformedCaptionError, Caption, start='1234', end='00:00:11.890', text='Hello Test!' ) def test_wrong_type_start_timestamp(self): self.assertRaises( MalformedCaptionError, Caption, start=1234, end='00:00:11.890', text='Hello Test!' ) def test_wrong_end_timestamp(self): self.assertRaises( MalformedCaptionError, Caption, start='00:00:07.000', end='1234', text='Hello Test!' ) def test_wrong_type_end_timestamp(self): self.assertRaises( MalformedCaptionError, Caption, start='00:00:07.000', end=1234, text='Hello Test!' ) def test_equality(self): caption1 = Caption( start='00:00:07.000', end='00:00:11.890', text='Hello test!', identifier='A test caption' ) caption2 = Caption( start='00:00:07.000', end='00:00:11.890', text='Hello test!', identifier='A test caption' ) self.assertTrue(caption1 == caption2) caption1 = Caption( start='00:00:02.000', end='00:00:11.890', text='Hello test!', identifier='A test caption' ) caption2 = Caption( start='00:00:07.000', end='00:00:11.890', text='Hello test!', identifier='A test caption' ) self.assertFalse(caption1 == caption2) self.assertFalse( Caption( start='00:00:07.000', end='00:00:11.890', text='Hello test!', identifier='A test caption' ) == 1234 ) def test_repr(self): caption = Caption( start='00:00:07.000', end='00:00:11.890', text='Hello test!', identifier='A test caption' ) self.assertEqual( repr(caption), "" ) def test_repr_with_settings(self): caption = Caption( start='00:00:07.000', end='00:00:11.890', text='Hello test!', settings='line:0 align:start' ) self.assertEqual( repr(caption), "" ) def test_settings_default(self): caption = Caption('00:00:07.000', '00:00:11.890', 'Hello test!') self.assertIsInstance(caption.settings, CueSettings) self.assertFalse(caption.settings) def test_settings_from_string(self): caption = Caption( '00:00:07.000', '00:00:11.890', 'Hello test!', settings='line:0 align:middle' ) self.assertEqual(caption.settings.line, '0') self.assertIsNone(caption.settings.align) self.assertEqual(str(caption.settings), 'line:0 align:middle') def test_settings_from_cue_settings_are_copied(self): settings = CueSettings(line='0') caption = Caption( '00:00:07.000', '00:00:11.890', 'Hello test!', settings=settings ) other = Caption('00:00:11.890', '00:00:13.000', 'Other') other.settings = caption.settings settings.line = '1' other.settings.line = '2' self.assertEqual(caption.settings.line, '0') self.assertEqual(other.settings.line, '2') def test_update_settings(self): caption = Caption( '00:00:07.000', '00:00:11.890', 'Hello test!', settings='line:0' ) caption.settings.align = 'start' self.assertEqual(str(caption.settings), 'line:0 align:start') caption.settings = 'size:50%' self.assertEqual(str(caption.settings), 'size:50%') caption.settings = None self.assertFalse(caption.settings) def test_settings_wrong_type(self): with self.assertRaises(MalformedCaptionError): Caption('00:00:07.000', '00:00:11.890', settings=['line:0']) def test_settings_not_validated(self): caption = Caption( '00:00:07.000', '00:00:11.890', settings='foo:bar line:0 line:1' ) self.assertEqual(str(caption.settings), 'foo:bar line:0 line:1') def test_not_equality_settings(self): self.assertNotEqual( Caption( '00:00:07.000', '00:00:11.890', 'Hello', settings='line:0' ), Caption('00:00:07.000', '00:00:11.890', 'Hello') ) self.assertEqual( Caption( '00:00:07.000', '00:00:11.890', 'Hello', settings='line:0' ), Caption( '00:00:07.000', '00:00:11.890', 'Hello', settings=CueSettings(line='0') ) ) def test_str(self): caption = Caption( start='00:00:07.000', end='00:00:11.890', text='Hello test!', identifier='A test caption' ) self.assertEqual( str(caption), '00:00:07.000 00:00:11.890 Hello test!' ) def test_accept_comments(self): caption = Caption( start='00:00:07.000', end='00:00:11.890', text='Hello test!', identifier='A test caption' ) caption.comments.append('One comment') caption.comments.append('Another comment') self.assertListEqual( caption.comments, ['One comment', 'Another comment'] ) def test_timestamp_update(self): c = Caption('00:00:00.500', '00:00:07.000') c.start = '00:00:01.750' c.end = '00:00:08.250' self.assertEqual(c.start, '00:00:01.750') self.assertEqual(c.end, '00:00:08.250') def test_timestamp_format(self): c = Caption('01:02:03.400', '02:03:04.500') self.assertEqual(c.start, '01:02:03.400') self.assertEqual(c.end, '02:03:04.500') c = Caption('02:03.400', '03:04.500') self.assertEqual(c.start, '00:02:03.400') self.assertEqual(c.end, '00:03:04.500') def test_update_text(self): c = Caption(text='Caption line #1') c.text = 'Caption line #1 updated' self.assertEqual( c.text, 'Caption line #1 updated' ) def test_update_text_multiline(self): c = Caption(text='Caption line #1') c.text = 'Caption line #1\nCaption line #2' self.assertEqual( len(c.lines), 2 ) self.assertEqual( c.text, 'Caption line #1\nCaption line #2' ) def test_text_only_split_on_cr_and_lf(self): caption = Caption(text='line 1\u2028still line 1\r\nline 2\x85\n') self.assertListEqual( caption.lines, ['line 1\u2028still line 1', 'line 2\x85'] ) caption.text = 'line 1\x0cstill line 1\rline 2\n' self.assertListEqual( caption.lines, ['line 1\x0cstill line 1', 'line 2'] ) def test_set_text_keeps_lines(self): caption = Caption(text=['line 1\u2028still line 1', 'line 2']) caption.text = caption.text self.assertListEqual( caption.lines, ['line 1\u2028still line 1', 'line 2'] ) def test_empty_text(self): self.assertListEqual(Caption(text='').lines, []) caption = Caption(text='Caption line #1') caption.text = '' self.assertListEqual(caption.lines, []) def test_update_text_wrong_type(self): c = Caption(text='Caption line #1') for value in (123, None, b'text', ['line 1', 2]): with self.subTest(value=value): with self.assertRaises(TypeError) as cm: c.text = value self.assertIn(repr(value), str(cm.exception)) self.assertListEqual(c.lines, ['Caption line #1']) def test_update_text_with_lines(self): c = Caption(text='Caption line #1') for value in (['line 1', 'line 2'], ('line 1', 'line 2')): with self.subTest(value=value): c.text = value self.assertListEqual(c.lines, ['line 1', 'line 2']) self.assertEqual(c.text, 'line 1\nline 2') lines = ['line 1'] c.text = lines lines.append('line 2') self.assertListEqual(c.lines, ['line 1']) def test_text_wrong_type(self): for text in (123, 0, b'text', b'', ['line 1', 2]): with self.subTest(text=text): with self.assertRaises(TypeError) as cm: Caption(text=text) self.assertIn(repr(text), str(cm.exception)) def test_text_from_iterable_of_lines(self): self.assertListEqual( Caption(text=(line for line in ('line 1', 'line 2'))).lines, ['line 1', 'line 2'] ) def test_manipulate_lines(self): c = Caption(text=['Caption line #1', 'Caption line #2']) c.lines[0] = 'Caption line #1 updated' self.assertEqual( c.lines[0], 'Caption line #1 updated' ) def test_malformed_start_timestamp(self): self.assertRaises( MalformedCaptionError, Caption, '01:00' ) def test_voice_span(self): caption = Caption(text='Hello there!') self.assertEqual(caption.text, 'Hello there!') self.assertEqual(caption.raw_text, 'Hello there!') self.assertEqual(caption.voice, 'Homer Simpson') def test_voice_span_with_classes(self): caption = Caption(text='I am Lisa') self.assertEqual(caption.text, 'I am Lisa') self.assertEqual( caption.raw_text, 'I am Lisa' ) self.assertEqual(caption.voice, 'Lisa Simpson') def test_voice_span_is_invalid(self): caption = Caption(text='I like tests') self.assertEqual(caption.voice, 'Homer Simpson') def test_voice_span_removed(self): caption = Caption(text='I like tests') self.assertEqual(caption.text, 'I like tests') self.assertEqual(caption.raw_text, 'I like tests') self.assertEqual(caption.voice, 'Homer Simpson') caption.text = 'This is a test' self.assertEqual(caption.text, 'This is a test') self.assertEqual(caption.raw_text, 'This is a test') self.assertIsNone(caption.voice) def test_voice_span_long_whitespace_is_linear(self): caption = Caption(text='Hello test!', 'a nested< one', 'first line\nsecond line <', '', ): caption = Caption(text=raw_text) self.assertEqual( caption.text, re.sub('<.*?>', '', raw_text) ) def test_text_unclosed_tags_are_linear(self): # re.sub('<.*?>', ...) took about 10 seconds on this input caption = Caption(text='<' * 64000) start = time.perf_counter() self.assertEqual(caption.text, '<' * 64000) self.assertLess(time.perf_counter() - start, 1) class TestStyle(unittest.TestCase): def test_instantiation(self): style = Style(text='::cue(b) {\ncolor: peachpuff;\n}') self.assertEqual(style.text, '::cue(b) {\ncolor: peachpuff;\n}') self.assertListEqual( style.lines, ['::cue(b) {', 'color: peachpuff;', '}'] ) def test_text_only_split_on_cr_and_lf(self): style = Style(text='::cue {\x0c color: red;\r\n}\n') self.assertListEqual(style.lines, ['::cue {\x0c color: red;', '}']) def test_text_accept_list_of_strings(self): style = Style(text=['::cue(b) {', 'color: peachpuff;', '}']) self.assertEqual(style.text, '::cue(b) {\ncolor: peachpuff;\n}') self.assertListEqual( style.lines, ['::cue(b) {', 'color: peachpuff;', '}'] ) def test_text_list_is_copied(self): lines = ['::cue(b) {', 'color: peachpuff;', '}'] style = Style(lines) lines.append('::cue(i) {}') self.assertListEqual( style.lines, ['::cue(b) {', 'color: peachpuff;', '}'] ) def test_accept_comments(self): style = Style(text='::cue(b) {\ncolor: peachpuff;\n}') style.comments.append('One comment') style.comments.append('Another comment') self.assertListEqual( style.comments, ['One comment', 'Another comment'] ) def test_get_text(self): style = Style(['::cue(b) {', ' color: peachpuff;', '}']) self.assertEqual( style.text, '::cue(b) {\n color: peachpuff;\n}' ) class TestCueSettings(unittest.TestCase): def test_instantiation_without_settings(self): settings = CueSettings() self.assertEqual(str(settings), '') self.assertFalse(settings) self.assertIsNone(settings.vertical) self.assertIsNone(settings.line) self.assertIsNone(settings.position) self.assertIsNone(settings.size) self.assertIsNone(settings.align) self.assertIsNone(settings.region) def test_instantiation(self): settings = CueSettings( vertical='rl', line='10%,end', position='50%,line-left', size='80%', align='start', region='fred' ) self.assertTrue(settings) self.assertEqual(settings.vertical, 'rl') self.assertEqual(settings.line, '10%,end') self.assertEqual(settings.position, '50%,line-left') self.assertEqual(settings.size, '80%') self.assertEqual(settings.align, 'start') self.assertEqual(settings.region, 'fred') self.assertEqual( str(settings), 'vertical:rl line:10%,end position:50%,line-left size:80% ' 'align:start region:fred' ) def test_instantiation_only_keyword_arguments(self): with self.assertRaises(TypeError): CueSettings('rl') def test_instantiation_invalid_value(self): with self.assertRaises(MalformedCaptionError): CueSettings(align='middle') def test_from_string(self): settings = CueSettings.from_string('line:0 align:start') self.assertEqual(settings.line, '0') self.assertEqual(settings.align, 'start') self.assertIsNone(settings.position) self.assertEqual(str(settings), 'line:0 align:start') def test_from_string_empty(self): for value in ('', ' ', ' \t '): settings = CueSettings.from_string(value) self.assertFalse(settings) self.assertEqual(str(settings), '') def test_from_string_keeps_order(self): settings = CueSettings.from_string( 'align:end size:50% vertical:lr line:2' ) self.assertEqual( str(settings), 'align:end size:50% vertical:lr line:2' ) def test_from_string_splits_on_spaces(self): settings = CueSettings.from_string( ' line:0\talign:start\x0csize:50%\r\nregion:fred ' ) self.assertEqual(settings.line, '0') self.assertEqual(settings.align, 'start') self.assertEqual(settings.size, '50%') self.assertEqual(settings.region, 'fred') self.assertEqual( str(settings), 'line:0 align:start size:50% region:fred' ) def test_from_string_only_splits_on_ascii_spaces(self): settings = CueSettings.from_string('line:0\xa0align:start') self.assertIsNone(settings.line) self.assertIsNone(settings.align) self.assertEqual(str(settings), 'line:0\xa0align:start') def test_from_string_keeps_invalid_settings(self): settings = CueSettings.from_string( 'line:90% position:50% align:middle' ) self.assertEqual(settings.line, '90%') self.assertEqual(settings.position, '50%') self.assertIsNone(settings.align) self.assertEqual( str(settings), 'line:90% position:50% align:middle' ) def test_from_string_keeps_unknown_settings(self): settings = CueSettings.from_string('foo:bar line:0 baz :x y:') self.assertEqual(settings.line, '0') self.assertEqual(str(settings), 'foo:bar line:0 baz :x y:') def test_from_string_repeated_setting(self): settings = CueSettings.from_string('line:0 align:start line:5') self.assertEqual(settings.line, '5') self.assertEqual(str(settings), 'line:0 align:start line:5') def test_from_string_repeated_setting_last_valid(self): settings = CueSettings.from_string('line:0 line:top') self.assertEqual(settings.line, '0') def test_from_string_value_with_colon(self): settings = CueSettings.from_string('region:a:b') self.assertEqual(settings.region, 'a:b') def test_from_string_wrong_type(self): with self.assertRaises(MalformedCaptionError): CueSettings.from_string(None) def test_from_string_strict(self): settings = CueSettings.from_string( 'vertical:lr line:-1,center position:10%,line-right size:0% ' 'align:right region:fred', strict=True ) self.assertEqual(settings.line, '-1,center') self.assertEqual(settings.region, 'fred') def test_from_string_strict_invalid_settings(self): for value in ('align:middle', 'line:0 foo:bar', 'line:0 baz', ':start', 'align:', ): with self.subTest(value=value): with self.assertRaises(MalformedCaptionError): CueSettings.from_string(value, strict=True) def test_from_string_strict_repeated_setting(self): with self.assertRaises(MalformedCaptionError) as cm: CueSettings.from_string('line:0 line:5', strict=True) self.assertIn('line:5', str(cm.exception)) def test_valid_values(self): valid_values = { 'vertical': ('rl', 'lr'), 'line': ('0', '-1', '12', '0%', '100%', '33.5%', '10%,start', '2,center', '-3,end'), 'position': ('0%', '50%', '100%', '12.25%', '50%,line-left', '50%,center', '50%,line-right'), 'size': ('0%', '80%', '100%', '99.9%'), 'align': ('start', 'center', 'end', 'left', 'right'), 'region': ('fred', 'a:b', 'région-1'), } for name, values in valid_values.items(): for value in values: with self.subTest(name=name, value=value): settings = CueSettings() setattr(settings, name, value) self.assertEqual(getattr(settings, name), value) self.assertEqual(str(settings), f'{name}:{value}') self.assertEqual( getattr(CueSettings.from_string(str(settings)), name), value ) def test_invalid_values(self): invalid_values = { 'vertical': ('', 'RL', 'rr'), 'line': ('', 'top', '1.5', '--1', '1-', '+1', '-10%', '101%', '10%,', '10%,top', '0,start,end', '٣'), 'position': ('', '50', '-1%', '100.1%', '.5%', '5.%', '50%,', '50%,start', '٥٠%'), 'size': ('', '80', '101%', '1e2%', 'auto'), 'align': ('', 'middle', 'Start', 'line-left'), 'region': ('', 'a-->b'), } for name, values in invalid_values.items(): for value in values: with self.subTest(name=name, value=value): with self.assertRaises(MalformedCaptionError): setattr(CueSettings(), name, value) self.assertIsNone( getattr( CueSettings.from_string(f'{name}:{value}'), name ) ) def test_set_value_with_spaces(self): for name, value in (('vertical', 'rl '), ('line', '0 align:start'), ('size', ' 50%'), ('region', 'a b'), ('region', 'a\tb'), ('region', 'a\nb'), ): with self.subTest(name=name, value=value): with self.assertRaises(MalformedCaptionError): setattr(CueSettings(), name, value) def test_set_wrong_type(self): for value in (0, 50.0, b'start'): with self.subTest(value=value): with self.assertRaises(MalformedCaptionError): CueSettings().line = value def test_set_adds_setting_at_the_end(self): settings = CueSettings.from_string('line:0 foo:bar') settings.align = 'start' self.assertEqual(str(settings), 'line:0 foo:bar align:start') def test_set_replaces_setting_in_place(self): settings = CueSettings.from_string('line:0 align:start size:50%') settings.align = 'end' self.assertEqual(str(settings), 'line:0 align:end size:50%') def test_set_replaces_invalid_setting(self): settings = CueSettings.from_string('line:90% align:middle') settings.align = 'center' self.assertEqual(settings.align, 'center') self.assertEqual(str(settings), 'line:90% align:center') def test_set_replaces_repeated_setting(self): settings = CueSettings.from_string('line:0 align:start line:5') settings.line = '-1' self.assertEqual(str(settings), 'line:-1 align:start') def test_set_invalid_value_keeps_settings(self): settings = CueSettings.from_string('line:0 align:start') with self.assertRaises(MalformedCaptionError): settings.align = 'middle' self.assertEqual(str(settings), 'line:0 align:start') def test_set_none_removes_setting(self): settings = CueSettings.from_string( 'line:0 align:middle size:50% align:start' ) settings.align = None self.assertIsNone(settings.align) self.assertEqual(str(settings), 'line:0 size:50%') def test_set_none_without_setting(self): settings = CueSettings.from_string('line:0') settings.align = None self.assertEqual(str(settings), 'line:0') def test_set_does_not_change_unknown_settings(self): settings = CueSettings.from_string('line foo:bar line:0') settings.line = None self.assertEqual(str(settings), 'line foo:bar') def test_equality(self): self.assertEqual( CueSettings.from_string('line:0 align:start'), CueSettings(line='0', align='start') ) self.assertEqual(CueSettings(), CueSettings.from_string('')) def test_not_equality(self): self.assertNotEqual( CueSettings.from_string('line:0 align:start'), CueSettings.from_string('align:start line:0') ) self.assertNotEqual( CueSettings.from_string('line:0'), CueSettings.from_string('line:1') ) self.assertNotEqual(CueSettings.from_string('line:0'), 'line:0') def test_repr(self): self.assertEqual( repr(CueSettings.from_string('line:0 align:start')), "" ) def test_iterate_settings(self): settings = CueSettings.from_string('line:0 foo align:middle') self.assertListEqual(list(settings), ['line:0', 'foo', 'align:middle']) self.assertListEqual(list(CueSettings()), []) class TestRegion(unittest.TestCase): def test_instantiation_without_settings(self): region = Region() self.assertFalse(region) self.assertEqual(str(region), '') self.assertIsNone(region.id) self.assertIsNone(region.width) self.assertIsNone(region.lines) self.assertIsNone(region.regionanchor) self.assertIsNone(region.viewportanchor) self.assertIsNone(region.scroll) self.assertListEqual(region.comments, []) def test_instantiation(self): region = Region( id='fred', width='40%', lines='3', regionanchor='0%,100%', viewportanchor='10%,90%', scroll='up' ) self.assertEqual(region.id, 'fred') self.assertEqual(region.width, '40%') self.assertEqual(region.lines, '3') self.assertEqual(region.regionanchor, '0%,100%') self.assertEqual(region.viewportanchor, '10%,90%') self.assertEqual(region.scroll, 'up') self.assertListEqual( list(region), ['id:fred', 'width:40%', 'lines:3', 'regionanchor:0%,100%', 'viewportanchor:10%,90%', 'scroll:up'] ) def test_instantiation_only_keyword_arguments(self): with self.assertRaises(TypeError): Region('fred') def test_instantiation_invalid_value(self): with self.assertRaises(MalformedRegionError) as cm: Region(scroll='down') self.assertIn('scroll region setting', str(cm.exception)) def test_from_string_multiple_lines(self): region = Region.from_string( 'id:fred width:40%\nlines:3\r\nscroll:up' ) self.assertEqual(region.id, 'fred') self.assertEqual(region.lines, '3') self.assertListEqual( list(region), ['id:fred', 'width:40%', 'lines:3', 'scroll:up'] ) self.assertListEqual(region.comments, []) def test_from_string_keeps_invalid_and_unknown_settings(self): region = Region.from_string('id:fred lines:2.5 foo:bar id:bill') self.assertEqual(region.id, 'bill') self.assertIsNone(region.lines) self.assertListEqual( list(region), ['id:fred', 'lines:2.5', 'foo:bar', 'id:bill'] ) def test_from_string_strict(self): region = Region.from_string( 'id:fred width:40% lines:3 regionanchor:0%,100% ' 'viewportanchor:10%,90% scroll:up', strict=True ) self.assertEqual(region.viewportanchor, '10%,90%') def test_from_string_strict_invalid_settings(self): for value, message in (('id:fred scroll:down', 'Invalid'), ('id:fred foo:bar', 'Invalid'), ('id:fred id:bill', 'Repeated'), ): with self.subTest(value=value): with self.assertRaises(MalformedRegionError) as cm: Region.from_string(value, strict=True) self.assertIn(f'{message} region setting', str(cm.exception)) def test_from_string_wrong_type(self): with self.assertRaises(MalformedRegionError) as cm: Region.from_string(None) self.assertIn('Invalid region settings', str(cm.exception)) def test_valid_values(self): valid_values = { 'id': ('fred', 'a:b', 'région-1'), 'width': ('0%', '40%', '100%', '33.3%'), 'lines': ('0', '3', '12'), 'regionanchor': ('0%,100%', '50.5%,0%'), 'viewportanchor': ('10%,90%', '100%,100%'), 'scroll': ('up',), } for name, values in valid_values.items(): for value in values: with self.subTest(name=name, value=value): region = Region() setattr(region, name, value) self.assertEqual(getattr(region, name), value) self.assertEqual( getattr(Region.from_string(str(region)), name), value ) def test_invalid_values(self): invalid_values = { 'id': ('',), 'width': ('', '40', '101%', '-1%'), 'lines': ('', '-1', '2.5', '+3', '\u0663'), 'regionanchor': ('', '0%', '0%,', ',100%', '0%,100%,0%', '0,100', '101%,0%'), 'viewportanchor': ('', '10%;90%', '10%,-90%'), 'scroll': ('', 'down', 'Up', 'none'), } for name, values in invalid_values.items(): for value in values: with self.subTest(name=name, value=value): with self.assertRaises(MalformedRegionError): setattr(Region(), name, value) self.assertIsNone( getattr(Region.from_string(f'{name}:{value}'), name) ) def test_set_value_with_arrow(self): with self.assertRaises(MalformedRegionError): Region().id = 'a-->b' def test_from_string_with_arrow(self): for value in ('id:a-->b', 'id:fred -->', 'id:fred\n-->'): with self.subTest(value=value): with self.assertRaises(MalformedRegionError) as cm: Region.from_string(value) self.assertIn('Invalid region settings', str(cm.exception)) def test_set_value_with_spaces(self): for value in ('fred bill', 'fred\nbill'): with self.subTest(value=value): with self.assertRaises(MalformedRegionError): Region().id = value def test_update_settings(self): region = Region.from_string('id:fred width:40% scroll:down') region.width = '50%' region.scroll = 'up' region.lines = None region.regionanchor = '0%,100%' self.assertListEqual( list(region), ['id:fred', 'width:50%', 'scroll:up', 'regionanchor:0%,100%'] ) def test_accept_comments(self): region = Region(id='fred') region.comments.append('One comment') self.assertListEqual(region.comments, ['One comment']) def test_equality(self): region = Region.from_string('id:fred width:40%') other = Region(id='fred', width='40%') other.comments.append('Comments are not compared') self.assertEqual(region, other) self.assertNotEqual(region, Region(id='fred')) self.assertNotEqual( Region.from_string('id:fred'), CueSettings.from_string('id:fred') ) def test_repr(self): self.assertEqual( repr(Region(id='fred', width='40%')), "" ) glut23-webvtt-py-cca74e4/tests/test_sbv.py000066400000000000000000000200021527171777000206420ustar00rootroot00000000000000import unittest import textwrap from webvtt import sbv from webvtt.errors import MalformedFileError from webvtt.models import Caption class TestSBVCueBlock(unittest.TestCase): def test_is_valid(self): self.assertTrue(sbv.SBVCueBlock.is_valid(textwrap.dedent(''' 00:00:00.500,00:00:07.000 Caption #1 ''').strip().split('\n')) ) self.assertTrue(sbv.SBVCueBlock.is_valid(textwrap.dedent(''' 0:0:0.500,0:0:7.000 Caption #1 ''').strip().split('\n')) ) self.assertTrue(sbv.SBVCueBlock.is_valid(textwrap.dedent(''' 00:00:00.500,00:00:07.000 Caption #1 line 1 Caption #1 line 2 ''').strip().split('\n')) ) self.assertFalse(sbv.SBVCueBlock.is_valid(textwrap.dedent(''' 00:00:00.500 --> 00:00:07.000 Caption #1 ''').strip().split('\n')) ) self.assertFalse(sbv.SBVCueBlock.is_valid(textwrap.dedent(''' 1 00:00:00.500,00:00:07.000 Caption #1 ''').strip().split('\n')) ) self.assertFalse(sbv.SBVCueBlock.is_valid(textwrap.dedent(''' 1 00:00:00.500,00:00:07.000 ''').strip().split('\n')) ) self.assertFalse(sbv.SBVCueBlock.is_valid(textwrap.dedent(''' 1 Caption #1 ''').strip().split('\n')) ) self.assertFalse(sbv.SBVCueBlock.is_valid(textwrap.dedent(''' Caption #1 ''').strip().split('\n')) ) self.assertFalse(sbv.SBVCueBlock.is_valid(textwrap.dedent(''' 00:00:00.500,00:00:07.000 ''').strip().split('\n')) ) def test_from_lines(self): cue_block = sbv.SBVCueBlock.from_lines(textwrap.dedent(''' 00:00:00.500,00:00:07.000 Caption #1 line 1 Caption #1 line 2 ''').strip().split('\n') ) self.assertEqual( cue_block.start, '00:00:00.500' ) self.assertEqual( cue_block.end, '00:00:07.000' ) self.assertEqual( cue_block.payload, ['Caption #1 line 1', 'Caption #1 line 2'] ) def test_from_lines_shorter_timestamps(self): cue_block = sbv.SBVCueBlock.from_lines(textwrap.dedent(''' 0:1:2.500,0:1:03.800 Caption #1 line 1 Caption #1 line 2 ''').strip().split('\n') ) self.assertEqual( cue_block.start, '0:1:2.500' ) self.assertEqual( cue_block.end, '0:1:03.800' ) def test_from_lines_hours_with_more_than_two_digits(self): cue_block = sbv.SBVCueBlock.from_lines(textwrap.dedent(''' 100:00:00.000,100:00:05.000 Caption #1 ''').strip().split('\n') ) self.assertEqual( cue_block.start, '100:00:00.000' ) self.assertEqual( cue_block.end, '100:00:05.000' ) def test_is_valid_non_ascii_digits(self): self.assertFalse(sbv.SBVCueBlock.is_valid( ['0:00:0\u0661.500,0:00:07.000', 'Caption #1'] )) def test_is_valid_requires_dot_before_milliseconds(self): self.assertFalse(sbv.SBVCueBlock.is_valid(textwrap.dedent(''' 0:00:01x000,0:00:02.000 Caption #1 ''').strip().split('\n') )) self.assertFalse(sbv.SBVCueBlock.is_valid(textwrap.dedent(''' 0:00:01.000,0:00:02x000 Caption #1 ''').strip().split('\n') )) class TestSBVModule(unittest.TestCase): def test_parse_invalid_format(self): self.assertRaises( MalformedFileError, sbv.parse, textwrap.dedent(''' 1 00:00:00.500,00:00:07.000 Caption text #1 00:00:07.000,00:00:11.890 Caption text #2 ''').strip().split('\n') ) def test_parse_invalid_format_only_blank_lines(self): for lines in ([], [''], ['', ' ', '']): with self.subTest(lines=lines): with self.assertRaises(MalformedFileError): sbv.parse(lines) def test_parse_leading_blank_lines(self): for blank_lines in ([''], ['', ''], [' ', '\t']): with self.subTest(blank_lines=blank_lines): captions = sbv.parse( blank_lines + ['0:00:01.000,0:00:02.000', 'Caption #1'] ) self.assertEqual( [(c.start, c.text) for c in captions], [('00:00:01.000', 'Caption #1')] ) def test_parse_cue_without_text(self): self.assertEqual(sbv.parse(['0:00:01.000,0:00:02.000']), []) def test_parse_first_cue_without_text(self): captions = sbv.parse( textwrap.dedent(''' 0:00:01.000,0:00:02.000 0:00:03.000,0:00:04.000 Caption #2 ''').strip().split('\n') ) self.assertEqual( [(c.start, c.text) for c in captions], [('00:00:03.000', 'Caption #2')] ) def test_parse_captions(self): captions = sbv.parse( textwrap.dedent(''' 00:00:00.500,00:00:07.000 Caption #1 00:00:07.000,00:00:11.890 Caption #2 line 1 Caption #2 line 2 ''').strip().split('\n') ) self.assertEqual(len(captions), 2) self.assertIsInstance(captions[0], Caption) self.assertIsInstance(captions[1], Caption) self.assertEqual( str(captions[0]), '00:00:00.500 00:00:07.000 Caption #1' ) self.assertEqual( str(captions[1]), r'00:00:07.000 00:00:11.890 Caption #2 line 1\n' 'Caption #2 line 2' ) def test_parse_skips_cue_with_malformed_timings(self): captions = sbv.parse( textwrap.dedent(''' 00:00:00.500,00:00:07.000 Caption #1 00:00:07x000,00:00:11.890 Caption #2 00:00:11.890,00:00:16.320 Caption #3 ''').strip().split('\n') ) self.assertEqual( [caption.text for caption in captions], ['Caption #1', 'Caption #3'] ) def test_parse_ignores_text_attached_to_end_timestamp(self): for timings in ('0:00:01.000,0:00:02.0005', '0:00:01.000,0:00:02.000xyz'): captions = sbv.parse([timings, 'Caption #1']) self.assertEqual(len(captions), 1) self.assertEqual(captions[0].end, '00:00:02.000') def test_parse_timings_over_range(self): captions = sbv.parse(['0:59:61.000,0:61:00.500', 'Caption #1']) self.assertEqual(len(captions), 1) self.assertEqual(captions[0].start, '01:00:01.000') self.assertEqual(captions[0].end, '01:01:00.500') def test_parse_cue_with_too_many_digits(self): captions = sbv.parse( ['0:00:01.000,0:00:02.000', 'Caption #1', '', '9' * 5000 + ':00:03.000,0:00:04.000', 'Caption #2'] ) self.assertEqual(len(captions), 1) self.assertEqual(captions[0].text, 'Caption #1') def test_parse_skips_cue_with_too_many_digits_hours(self): captions = sbv.parse( ['0:00:01.000,0:00:02.000', 'Caption #1', '', '99999999999:00:03.000,99999999999:00:04.000', 'Caption #2'] ) self.assertEqual(len(captions), 1) self.assertEqual(captions[0].text, 'Caption #1') glut23-webvtt-py-cca74e4/tests/test_segmenter.py000066400000000000000000000765671527171777000220740ustar00rootroot00000000000000import os import unittest import tempfile import pathlib import textwrap import datetime import math import webvtt from webvtt import segmenter from webvtt.models import Caption, Timestamp PATH_TO_SAMPLES = pathlib.Path(__file__).resolve().parent / 'samples' class TestSegmenter(unittest.TestCase): def setUp(self): self.temp_dir = tempfile.TemporaryDirectory() def tearDown(self): self.temp_dir.cleanup() def read_folder(self, folder): return {path.name: path.read_text() for path in pathlib.Path(folder).iterdir()} def test_segmentation_with_defaults(self): segmenter.segment(PATH_TO_SAMPLES / 'sample.vtt', self.temp_dir.name) _, dirs, files = next(os.walk(self.temp_dir.name)) self.assertEqual(len(dirs), 0) self.assertEqual(len(files), 8) for expected_file in ('prog_index.m3u8', 'fileSequence0.webvtt', 'fileSequence1.webvtt', 'fileSequence2.webvtt', 'fileSequence3.webvtt', 'fileSequence4.webvtt', 'fileSequence5.webvtt', 'fileSequence6.webvtt', ): self.assertIn(expected_file, files) output_path = pathlib.Path(self.temp_dir.name) self.assertEqual( (output_path / 'prog_index.m3u8').read_text(), textwrap.dedent( ''' #EXTM3U #EXT-X-TARGETDURATION:10 #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXTINF:10.00000, fileSequence0.webvtt #EXTINF:10.00000, fileSequence1.webvtt #EXTINF:10.00000, fileSequence2.webvtt #EXTINF:10.00000, fileSequence3.webvtt #EXTINF:10.00000, fileSequence4.webvtt #EXTINF:10.00000, fileSequence5.webvtt #EXTINF:10.00000, fileSequence6.webvtt #EXT-X-ENDLIST ''' ).lstrip() ) self.assertEqual( (output_path / 'fileSequence0.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:11.890 Caption text #2 ''' ).lstrip() ) self.assertEqual( (output_path / 'fileSequence1.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:07.000 --> 00:00:11.890 Caption text #2 00:00:11.890 --> 00:00:16.320 Caption text #3 00:00:16.320 --> 00:00:21.580 Caption text #4 ''' ).lstrip() ) self.assertEqual( (output_path / 'fileSequence2.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:16.320 --> 00:00:21.580 Caption text #4 00:00:21.580 --> 00:00:23.880 Caption text #5 00:00:23.880 --> 00:00:27.280 Caption text #6 00:00:27.280 --> 00:00:30.280 Caption text #7 ''' ).lstrip() ) self.assertEqual( (output_path / 'fileSequence3.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:27.280 --> 00:00:30.280 Caption text #7 00:00:30.280 --> 00:00:36.510 Caption text #8 00:00:36.510 --> 00:00:38.870 Caption text #9 00:00:38.870 --> 00:00:45.000 Caption text #10 ''' ).lstrip() ) self.assertEqual( (output_path / 'fileSequence4.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:38.870 --> 00:00:45.000 Caption text #10 00:00:45.000 --> 00:00:47.000 Caption text #11 00:00:47.000 --> 00:00:50.970 Caption text #12 ''' ).lstrip() ) self.assertEqual( (output_path / 'fileSequence5.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:47.000 --> 00:00:50.970 Caption text #12 00:00:50.970 --> 00:00:54.440 Caption text #13 00:00:54.440 --> 00:00:58.600 Caption text #14 00:00:58.600 --> 00:01:01.350 Caption text #15 ''' ).lstrip() ) self.assertEqual( (output_path / 'fileSequence6.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:58.600 --> 00:01:01.350 Caption text #15 00:01:01.350 --> 00:01:04.300 Caption text #16 ''' ).lstrip() ) def test_segmentation_with_custom_values(self): segmenter.segment( webvtt_path=PATH_TO_SAMPLES / 'sample.vtt', output=self.temp_dir.name, seconds=30, mpegts=800000 ) _, dirs, files = next(os.walk(self.temp_dir.name)) self.assertEqual(len(dirs), 0) self.assertEqual(len(files), 4) for expected_file in ('prog_index.m3u8', 'fileSequence0.webvtt', 'fileSequence1.webvtt', 'fileSequence2.webvtt', ): self.assertIn(expected_file, files) output_path = pathlib.Path(self.temp_dir.name) self.assertEqual( (output_path / 'prog_index.m3u8').read_text(), textwrap.dedent( ''' #EXTM3U #EXT-X-TARGETDURATION:30 #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXTINF:30.00000, fileSequence0.webvtt #EXTINF:30.00000, fileSequence1.webvtt #EXTINF:30.00000, fileSequence2.webvtt #EXT-X-ENDLIST ''' ).lstrip() ) self.assertEqual( (output_path / 'fileSequence0.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:800000,LOCAL:00:00:00.000 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:11.890 Caption text #2 00:00:11.890 --> 00:00:16.320 Caption text #3 00:00:16.320 --> 00:00:21.580 Caption text #4 00:00:21.580 --> 00:00:23.880 Caption text #5 00:00:23.880 --> 00:00:27.280 Caption text #6 00:00:27.280 --> 00:00:30.280 Caption text #7 ''' ).lstrip() ) self.assertEqual( (output_path / 'fileSequence1.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:800000,LOCAL:00:00:00.000 00:00:27.280 --> 00:00:30.280 Caption text #7 00:00:30.280 --> 00:00:36.510 Caption text #8 00:00:36.510 --> 00:00:38.870 Caption text #9 00:00:38.870 --> 00:00:45.000 Caption text #10 00:00:45.000 --> 00:00:47.000 Caption text #11 00:00:47.000 --> 00:00:50.970 Caption text #12 00:00:50.970 --> 00:00:54.440 Caption text #13 00:00:54.440 --> 00:00:58.600 Caption text #14 00:00:58.600 --> 00:01:01.350 Caption text #15 ''' ).lstrip() ) self.assertEqual( (output_path / 'fileSequence2.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:800000,LOCAL:00:00:00.000 00:00:58.600 --> 00:01:01.350 Caption text #15 00:01:01.350 --> 00:01:04.300 Caption text #16 ''' ).lstrip() ) def test_manifest_uses_segment_duration(self): for seconds in (5, 10, 30, 45): with self.subTest(seconds=seconds): segmenter.write_manifest( pathlib.Path(self.temp_dir.name), [[], []], seconds ) manifest = ( pathlib.Path(self.temp_dir.name) / 'prog_index.m3u8' ).read_text() self.assertIn( f'#EXT-X-TARGETDURATION:{seconds}\n', manifest ) self.assertEqual( [line for line in manifest.splitlines() if line.startswith('#EXTINF:')], [f'#EXTINF:{seconds:.5f},'] * 2 ) def test_manifest_with_duration(self): output_folder = pathlib.Path(self.temp_dir.name) for duration, last in ((25.5, '5.50000'), (30, '10.00000'), (20.001, '0.00100')): with self.subTest(duration=duration): segmenter.write_manifest(output_folder, [[], [], []], 10, duration=duration) manifest = (output_folder / 'prog_index.m3u8').read_text() self.assertIn('#EXT-X-TARGETDURATION:10\n', manifest) self.assertEqual( [line for line in manifest.splitlines() if line.startswith('#EXTINF:')], ['#EXTINF:10.00000,', '#EXTINF:10.00000,', f'#EXTINF:{last},'] ) def test_manifest_duration_not_in_last_segment(self): output_folder = pathlib.Path(self.temp_dir.name) for segments, duration, message in ( ([[], [], []], 20, 'Duration 00:00:20.000 does not end in the last of 3 ' 'segments of 10 seconds'), ([[], [], []], 30.001, 'Duration 00:00:30.001 does not end in the last of 3 ' 'segments of 10 seconds'), ([], 5, 'Duration 00:00:05.000 does not end in the last of 0 ' 'segments of 10 seconds')): with self.subTest(segments=segments, duration=duration): with self.assertRaises(ValueError) as cm: segmenter.write_manifest(output_folder, segments, 10, duration=duration) self.assertEqual(str(cm.exception), message) self.assertEqual(os.listdir(output_folder), []) def test_slice_segments_with_duration(self): captions = [ Caption('00:00:00.500', '00:00:07.000', 'first'), Caption('00:00:22.000', '00:00:40.000', 'past the end'), ] for duration in (25.5, 25.5004, '00:00:25.500', '00:25.500', Timestamp(0, 0, 25, 500), datetime.timedelta(seconds=25.5)): with self.subTest(duration=duration): segments = segmenter.slice_segments(captions, 10, duration=duration) self.assertEqual( [[c.text for c in segment] for segment in segments], [['first'], [], ['past the end']] ) def test_slice_segments_with_duration_and_no_captions(self): self.assertEqual(segmenter.slice_segments([], 10, duration=1), [[]]) self.assertEqual(segmenter.slice_segments([], 10, duration=30), [[], [], []]) def test_slice_segments_caption_not_before_duration(self): for start in ('00:00:25.000', '00:01:00.000'): with self.subTest(start=start): captions = [ Caption('00:00:01.000', '00:00:02.000', 'first'), Caption(start, '00:02:00.000', 'after the end'), ] with self.assertRaises(ValueError) as cm: segmenter.slice_segments(captions, 10, duration=25) self.assertEqual( str(cm.exception), f'Caption at {start} does not start before the end of ' 'the media at 00:00:25.000' ) def test_slice_segments_invalid_duration(self): for duration, error in ((0, ValueError), (-1, ValueError), (0.0004, ValueError), (math.nan, ValueError), (math.inf, ValueError), ('00:00:00.000', ValueError), ('25 seconds', ValueError), (True, TypeError), ([25], TypeError)): with self.subTest(duration=duration): with self.assertRaises(error) as cm: segmenter.slice_segments([], 10, duration=duration) self.assertEqual(str(cm.exception), f'Invalid duration {duration!r}') def test_slice_segments_last_caption_ends_on_segment_boundary(self): captions = [ Caption('00:00.000', '00:09.000', 'first'), Caption('00:09.000', '00:10.000', 'second'), ] segments = segmenter.slice_segments(captions, 10) self.assertEqual( [[c.text for c in segment] for segment in segments], [['first', 'second']] ) def test_slice_segments_caption_ends_on_internal_boundary(self): captions = [ Caption('00:01.000', '00:10.000', 'first'), Caption('00:10.000', '00:10.001', 'second'), ] segments = segmenter.slice_segments(captions, 10) self.assertEqual( [[c.text for c in segment] for segment in segments], [['first'], ['second']] ) def test_slice_segments_last_caption_ends_mid_second(self): for end in ('00:10.001', '00:10.500'): with self.subTest(end=end): captions = [Caption('00:00.000', end, 'only')] segments = segmenter.slice_segments(captions, 10) self.assertEqual( [[c.text for c in segment] for segment in segments], [['only'], ['only']] ) def test_slice_segments_zero_duration_caption_on_boundary(self): captions = [Caption('00:10.000', '00:10.000', 'only')] segments = segmenter.slice_segments(captions, 10) self.assertEqual( [[c.text for c in segment] for segment in segments], [[], ['only']] ) def test_slice_segments_caption_ends_after_last_caption(self): captions = [ Caption('00:00.000', '00:35.000', 'long'), Caption('00:01.000', '00:05.000', 'short'), ] segments = segmenter.slice_segments(captions, 10) self.assertEqual( [[c.text for c in segment] for segment in segments], [['long', 'short'], ['long'], ['long'], ['long']] ) def test_slice_segments_caption_ends_before_it_starts(self): captions = [Caption('00:25.000', '00:05.000', 'backwards')] segments = segmenter.slice_segments(captions, 10) self.assertEqual( [[c.text for c in segment] for segment in segments], [[], [], ['backwards']] ) def test_slice_segments_caption_ends_before_it_starts_with_others(self): captions = [ Caption('00:01.000', '00:15.000', 'first'), Caption('00:12.000', '00:11.000', 'backwards'), Caption('00:30.000', '00:29.000', 'last'), ] segments = segmenter.slice_segments(captions, 10) self.assertEqual( [[c.text for c in segment] for segment in segments], [['first'], ['first', 'backwards'], [], ['last']] ) def test_segmentation_keeps_cue_settings(self): webvtt_path = pathlib.Path(self.temp_dir.name) / 'captions.vtt' webvtt_path.write_text(textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:07.000 line:0 align:start Caption text #1 ''').lstrip()) output_path = pathlib.Path(self.temp_dir.name) / 'output' segmenter.segment(webvtt_path, output_path) self.assertEqual( (output_path / 'fileSequence0.webvtt').read_text(), textwrap.dedent( ''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 00:00:00.500 --> 00:00:07.000 line:0 align:start Caption text #1 ''' ).lstrip() ) def test_segmentation_keeps_styles_regions_and_identifiers(self): webvtt_path = pathlib.Path(self.temp_dir.name) / 'captions.vtt' webvtt_path.write_text(textwrap.dedent(''' WEBVTT REGION id:fred width:40% NOTE comment of the style STYLE ::cue(.yellow) { color: yellow; } NOTE comment of the first caption intro 00:00:00.500 --> 00:00:07.000 region:fred Caption text #1 00:00:12.000 --> 00:00:14.000 Caption text #2 ''').lstrip()) output_path = pathlib.Path(self.temp_dir.name) / 'output' segmenter.segment(webvtt_path, output_path) header = textwrap.dedent(''' WEBVTT X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 REGION id:fred width:40% STYLE ::cue(.yellow) { color: yellow; } ''').lstrip() self.assertEqual( (output_path / 'fileSequence0.webvtt').read_text(), header + textwrap.dedent(''' intro 00:00:00.500 --> 00:00:07.000 region:fred Caption text #1 ''') ) self.assertEqual( (output_path / 'fileSequence1.webvtt').read_text(), header + textwrap.dedent(''' 00:00:12.000 --> 00:00:14.000 Caption text #2 ''') ) def test_segment_invalid_arguments(self): webvtt_path = PATH_TO_SAMPLES / 'sample.vtt' output_path = pathlib.Path(self.temp_dir.name) / 'output' for arguments, error in (({'seconds': 0}, ValueError), ({'seconds': -10}, ValueError), ({'seconds': 2.5}, TypeError), ({'seconds': '10'}, TypeError), ({'seconds': True}, TypeError), ({'mpegts': -1}, ValueError), ({'mpegts': 2 ** 33}, ValueError), ({'mpegts': 9e5}, TypeError), ({'mpegts': None}, TypeError), ({'duration': 0}, ValueError), ({'duration': 1}, ValueError), ({'duration': b'25'}, TypeError)): with self.subTest(arguments=arguments): with self.assertRaises(error): segmenter.segment(webvtt_path, output_path, **arguments) self.assertFalse(output_path.exists()) def test_segment_functions_validate_arguments(self): output_folder = pathlib.Path(self.temp_dir.name) captions = [Caption('00:00:00.500', '00:00:07.000', 'Caption')] for call, error in ( (lambda: segmenter.slice_segments(captions, 0), ValueError), (lambda: segmenter.write_manifest(output_folder, [], 2.5), TypeError), (lambda: segmenter.write_segments(output_folder, [captions], -1), ValueError)): with self.assertRaises(error): call() self.assertEqual(os.listdir(output_folder), []) def test_write_segments_rejected_caption_writes_no_files(self): output_folder = pathlib.Path(self.temp_dir.name) for index in range(3): (output_folder / f'fileSequence{index}.webvtt').write_text( f'previous run {index}' ) with self.assertRaises(ValueError): segmenter.write_segments( output_folder, [[Caption('00:00:01.000', '00:00:02.000', 'Caption')], [], [Caption('00:00:21.000', '00:00:22.000', 'a\n\nb')]], segmenter.DEFAULT_MPEGTS ) for index in range(3): self.assertEqual( (output_folder / f'fileSequence{index}.webvtt').read_text(), f'previous run {index}' ) def test_segment_into_folder_with_earlier_segments(self): output_folder = pathlib.Path(self.temp_dir.name) segmenter.segment(PATH_TO_SAMPLES / 'sample.vtt', output_folder) earlier_files = self.read_folder(output_folder) with self.assertRaises(FileExistsError) as cm: segmenter.segment(PATH_TO_SAMPLES / 'one_caption.vtt', output_folder) self.assertEqual( str(cm.exception), f'{str(output_folder)!r} has the files of an earlier ' "segmentation, such as 'fileSequence0.webvtt'; use " 'overwrite=True to replace them' ) self.assertEqual(self.read_folder(output_folder), earlier_files) def test_segment_into_folder_with_earlier_playlist(self): output_folder = pathlib.Path(self.temp_dir.name) (output_folder / 'prog_index.m3u8').write_text('video playlist') with self.assertRaisesRegex(FileExistsError, "'prog_index.m3u8'"): segmenter.segment(PATH_TO_SAMPLES / 'one_caption.vtt', output_folder) self.assertEqual(os.listdir(output_folder), ['prog_index.m3u8']) self.assertEqual((output_folder / 'prog_index.m3u8').read_text(), 'video playlist') def test_segment_into_folder_with_earlier_files_in_other_case(self): output_folder = pathlib.Path(self.temp_dir.name) for name in ['PROG_INDEX.M3U8', 'FileSequence3.WebVTT']: (output_folder / name).write_text('earlier run') with self.assertRaisesRegex(FileExistsError, repr(name)): segmenter.segment(PATH_TO_SAMPLES / 'one_caption.vtt', output_folder) (output_folder / name).unlink() def test_segment_into_folder_with_other_files(self): output_folder = pathlib.Path(self.temp_dir.name) others = ['fileSequence01.webvtt', 'fileSequence5.vtt', 'fileSequence-1.webvtt', 'notes.txt'] for name in others: (output_folder / name).write_text('other file') segmenter.segment(PATH_TO_SAMPLES / 'one_caption.vtt', output_folder) self.assertEqual( sorted(os.listdir(output_folder)), sorted(others + ['fileSequence0.webvtt', 'prog_index.m3u8']) ) def test_segment_overwrite(self): output_folder = pathlib.Path(self.temp_dir.name) segmenter.segment(PATH_TO_SAMPLES / 'sample.vtt', output_folder) self.assertIn('fileSequence6.webvtt', os.listdir(output_folder)) others = ['fileSequence01.webvtt', 'fileSequence5.vtt', 'fileSequence-1.webvtt', 'notes.txt'] for name in others: (output_folder / name).write_text('other file') (output_folder / 'fileSequence9.webvtt').mkdir() segmenter.segment(PATH_TO_SAMPLES / 'one_caption.vtt', output_folder, overwrite=True) self.assertEqual( sorted(os.listdir(output_folder)), sorted(others + ['fileSequence0.webvtt', 'fileSequence9.webvtt', 'prog_index.m3u8']) ) for name in others: self.assertEqual((output_folder / name).read_text(), 'other file') manifest = (output_folder / 'prog_index.m3u8').read_text() self.assertIn('fileSequence0.webvtt', manifest) self.assertNotIn('fileSequence1.webvtt', manifest) self.assertIn('00:00:00.500 --> 00:00:07.000', (output_folder / 'fileSequence0.webvtt').read_text()) def test_segment_overwrite_without_earlier_files(self): output_folder = pathlib.Path(self.temp_dir.name) / 'output' segmenter.segment(PATH_TO_SAMPLES / 'one_caption.vtt', output_folder, overwrite=True) self.assertEqual(sorted(os.listdir(output_folder)), ['fileSequence0.webvtt', 'prog_index.m3u8']) def test_segmentation_mpegts_range(self): output_path = pathlib.Path(self.temp_dir.name) / 'output' segmenter.segment(PATH_TO_SAMPLES / 'one_caption.vtt', output_path, mpegts=2 ** 33 - 1) self.assertIn( 'X-TIMESTAMP-MAP=MPEGTS:8589934591,LOCAL:00:00:00.000', (output_path / 'fileSequence0.webvtt').read_text() ) def test_segment_with_duration(self): output_folder = pathlib.Path(self.temp_dir.name) segmenter.segment(PATH_TO_SAMPLES / 'one_caption.vtt', output_folder, duration='00:00:25.500') self.assertEqual( sorted(os.listdir(output_folder)), ['fileSequence0.webvtt', 'fileSequence1.webvtt', 'fileSequence2.webvtt', 'prog_index.m3u8'] ) self.assertEqual( (output_folder / 'fileSequence2.webvtt').read_text(), 'WEBVTT\n' 'X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000\n' ) self.assertEqual( (output_folder / 'prog_index.m3u8').read_text(), textwrap.dedent( ''' #EXTM3U #EXT-X-TARGETDURATION:10 #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXTINF:10.00000, fileSequence0.webvtt #EXTINF:10.00000, fileSequence1.webvtt #EXTINF:5.50000, fileSequence2.webvtt #EXT-X-ENDLIST ''' ).lstrip() ) def test_segment_invalid_duration_before_reading_file(self): with self.assertRaises(ValueError): segmenter.segment(PATH_TO_SAMPLES / 'missing.vtt', self.temp_dir.name, duration=0) def test_segment_webvtt_instance(self): from_path = pathlib.Path(self.temp_dir.name) / 'from_path' from_instance = pathlib.Path(self.temp_dir.name) / 'from_instance' vtt = webvtt.read(PATH_TO_SAMPLES / 'regions.vtt') captions = [(c.start, c.end, c.raw_text) for c in vtt.captions] segmenter.segment(PATH_TO_SAMPLES / 'regions.vtt', from_path) segmenter.segment(vtt, from_instance) self.assertEqual(self.read_folder(from_instance), self.read_folder(from_path)) self.assertEqual( [(c.start, c.end, c.raw_text) for c in vtt.captions], captions ) self.assertEqual(vtt.file, str(PATH_TO_SAMPLES / 'regions.vtt')) def test_segment_captions_in_memory(self): vtt = webvtt.WebVTT(captions=[ Caption('00:00:01.000', '00:00:04.000', 'Hello'), Caption('00:00:12.000', '00:00:13.000', 'World'), ]) vtt.shift(1) segmenter.segment(vtt, self.temp_dir.name, seconds=5) self.assertEqual( self.read_folder(self.temp_dir.name)['fileSequence2.webvtt'], 'WEBVTT\n' 'X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000\n' '\n' '00:00:13.000 --> 00:00:14.000\n' 'World\n' ) self.assertIsNone(vtt.file) def test_segment_captions_read_from_srt(self): from_vtt = pathlib.Path(self.temp_dir.name) / 'from_vtt' from_srt = pathlib.Path(self.temp_dir.name) / 'from_srt' srt_captions = webvtt.from_srt(PATH_TO_SAMPLES / 'sample.srt') segmenter.segment(srt_captions, from_srt) segmenter.segment(webvtt.from_string(srt_captions.content), from_vtt) self.assertEqual(self.read_folder(from_srt), self.read_folder(from_vtt)) def test_segment_with_no_captions(self): segmenter.segment( webvtt_path=PATH_TO_SAMPLES / 'no_captions.vtt', output=self.temp_dir.name ) _, dirs, files = next(os.walk(self.temp_dir.name)) self.assertEqual(len(dirs), 0) self.assertEqual(len(files), 2) self.assertIn('prog_index.m3u8', files) self.assertIn('fileSequence0.webvtt', files) self.assertEqual( (pathlib.Path(self.temp_dir.name) / 'fileSequence0.webvtt').read_text(), 'WEBVTT\n' 'X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000\n' ) self.assertEqual( (pathlib.Path(self.temp_dir.name) / 'prog_index.m3u8').read_text(), textwrap.dedent( ''' #EXTM3U #EXT-X-TARGETDURATION:10 #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXTINF:10.00000, fileSequence0.webvtt #EXT-X-ENDLIST ''' ).lstrip() ) def test_write_segments_error_keeps_file(self): output_folder = pathlib.Path(self.temp_dir.name) segment_file = output_folder / 'fileSequence0.webvtt' segment_file.write_text('old') with self.assertRaises(UnicodeEncodeError): segmenter.write_segments( output_folder, [[Caption('00:00:00.000', '00:00:01.000', '\ud800')]], segmenter.DEFAULT_MPEGTS ) self.assertEqual(segment_file.read_text(), 'old') glut23-webvtt-py-cca74e4/tests/test_srt.py000066400000000000000000000716451527171777000207030ustar00rootroot00000000000000import unittest import io import textwrap from webvtt import srt from webvtt.errors import MalformedFileError from webvtt.models import Caption, Style class TestSRTCueBlock(unittest.TestCase): def test_is_valid(self): self.assertTrue(srt.SRTCueBlock.is_valid(textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 Caption #1 ''').strip().split('\n')) ) self.assertTrue(srt.SRTCueBlock.is_valid(textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 Caption #1 line 1 Caption #1 line 2 ''').strip().split('\n')) ) # a cue without an index self.assertTrue(srt.SRTCueBlock.is_valid(textwrap.dedent(''' 00:00:00,500 --> 00:00:07,000 Caption #1 ''').strip().split('\n')) ) self.assertTrue(srt.SRTCueBlock.is_valid(textwrap.dedent(''' 1 00:00:00.500 --> 00:00:07.000 Caption #1 ''').strip().split('\n')) ) self.assertTrue(srt.SRTCueBlock.is_valid(textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 ''').strip().split('\n')) ) self.assertFalse(srt.SRTCueBlock.is_valid(textwrap.dedent(''' 1 Caption #1 ''').strip().split('\n')) ) self.assertFalse(srt.SRTCueBlock.is_valid(textwrap.dedent(''' Caption #1 ''').strip().split('\n')) ) self.assertTrue(srt.SRTCueBlock.is_valid(textwrap.dedent(''' 00:00:00,500 --> 00:00:07,000 ''').strip().split('\n')) ) def test_is_valid_index_with_whitespace(self): self.assertTrue(srt.SRTCueBlock.is_valid( ['1 ', '00:00:00,500 --> 00:00:07,000', 'Caption #1'] )) def test_is_valid_non_ascii_digits(self): for index, timings in ( ('\u0661', '00:00:00,500 --> 00:00:07,000'), ('\u00b2', '00:00:00,500 --> 00:00:07,000'), ('1', '00:00:00,\u0665\u0660\u0660 --> 00:00:07,000'), ): with self.subTest(index=index, timings=timings): self.assertFalse(srt.SRTCueBlock.is_valid( [index, timings, 'Caption #1'] )) def test_is_valid_text_after_end_timestamp(self): self.assertTrue(srt.SRTCueBlock.is_valid( ['1', '00:00:00,500 --> 00:00:07,000 X1:100 X2:200', 'Caption #1'] )) def test_from_lines_without_index(self): cue_block = srt.SRTCueBlock.from_lines( ['00:00:00,500 --> 00:00:07,000', 'Caption #1 line 1', 'Caption #1 line 2'] ) self.assertIsNone(cue_block.index) self.assertEqual(cue_block.start, '00:00:00,500') self.assertEqual(cue_block.end, '00:00:07,000') self.assertEqual( cue_block.payload, ['Caption #1 line 1', 'Caption #1 line 2'] ) def test_from_lines(self): cue_block = srt.SRTCueBlock.from_lines(textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 Caption #1 line 1 Caption #1 line 2 ''').strip().split('\n') ) self.assertEqual(cue_block.index, '1') self.assertEqual( cue_block.start, '00:00:00,500' ) self.assertEqual( cue_block.end, '00:00:07,000' ) self.assertEqual( cue_block.payload, ['Caption #1 line 1', 'Caption #1 line 2'] ) class TestSRTModule(unittest.TestCase): def test_parse_timestamps(self): for value, expected in (('00:00:07,500', '00:00:07.500'), ('00:00:07.500', '00:00:07.500'), ('0:0:7,500', '00:00:07.500'), ('00:00:07,5', '00:00:07.005'), ('00:00:07,50', '00:00:07.050'), ('00:00:07,0005', '00:00:07.005'), ('00:00:07,1500', '00:00:08.500'), ('00:00:61,000', '00:01:01.000'), ('00:61:01,000', '01:01:01.000'), ('100:00:07,000', '100:00:07.000')): captions = srt.parse(['1', f'{value} --> {value}', 'Caption']) self.assertEqual(captions[0].start, expected) def test_parse_invalid_format(self): self.assertRaises( MalformedFileError, srt.parse, textwrap.dedent(''' 00:00:00,500 --> 00:00:07,000 Caption text #1 00:00:07,000 --> 00:00:11,890 Caption text #2 ''').strip().split('\n') ) def test_parse_invalid_format_first_line_not_an_index(self): self.assertRaises( MalformedFileError, srt.parse, ['Caption #1', '00:00:00,500 --> 00:00:07,000', 'Caption #2'] ) def test_parse_invalid_format_only_blank_lines(self): for lines in ([], [''], ['', ' ', '']): with self.subTest(lines=lines): with self.assertRaises(MalformedFileError): srt.parse(lines) def test_parse_leading_blank_lines(self): for blank_lines in ([''], ['', ''], [' ', '\t']): with self.subTest(blank_lines=blank_lines): captions = srt.parse( blank_lines + ['1', '00:00:01,000 --> 00:00:02,000', 'Caption #1'] ) self.assertEqual( [(c.start, c.text) for c in captions], [('00:00:01.000', 'Caption #1')] ) def test_parse_cue_without_text(self): self.assertEqual( srt.parse(['1', '00:00:01,000 --> 00:00:02,000']), [] ) def test_parse_first_cue_without_text(self): captions = srt.parse( textwrap.dedent(''' 1 00:00:01,000 --> 00:00:02,000 2 00:00:03,000 --> 00:00:04,000 Caption #2 ''').strip().split('\n') ) self.assertEqual( [(c.start, c.text) for c in captions], [('00:00:03.000', 'Caption #2')] ) def test_parse_captions(self): captions = srt.parse( textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 Caption #1 2 00:00:07,000 --> 00:00:11,890 Caption #2 line 1 Caption #2 line 2 ''').strip().split('\n') ) self.assertEqual(len(captions), 2) self.assertIsInstance(captions[0], Caption) self.assertIsInstance(captions[1], Caption) self.assertEqual( str(captions[0]), '00:00:00.500 00:00:07.000 Caption #1' ) self.assertEqual( str(captions[1]), r'00:00:07.000 00:00:11.890 Caption #2 line 1\n' 'Caption #2 line 2' ) def test_parse_captions_with_empty_lines_in_text(self): captions = srt.parse( textwrap.dedent(''' 1 00:02:05,988 --> 00:02:10,987 CHAPITRE 12 BAPTÊME ET PARADIS DES DIEUX 2 00:02:13,278 --> 00:02:14,367 Je vois… ''').strip().split('\n') ) self.assertEqual(len(captions), 2) self.assertEqual( captions[0].lines, ['CHAPITRE 12', 'BAPTÊME ET PARADIS DES DIEUX'] ) self.assertEqual(captions[1].lines, ['Je vois…']) def test_parse_captions_with_digits_only_line_after_empty_line(self): captions = srt.parse( textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 The year was 1984 and then 2 00:00:07,000 --> 00:00:11,890 Caption #2 ''').strip().split('\n') ) self.assertEqual(len(captions), 2) self.assertEqual(captions[0].lines, ['The year was', '1984', 'and then']) self.assertEqual(captions[1].lines, ['Caption #2']) def test_parse_captions_with_digits_only_line_starting_a_block(self): captions = srt.parse( textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 Countdown 3 2 1 2 00:00:07,000 --> 00:00:11,890 Caption #2 ''').strip().split('\n') ) self.assertEqual(len(captions), 2) self.assertEqual(captions[0].lines, ['Countdown', '3', '2', '1']) self.assertEqual(captions[1].lines, ['Caption #2']) def test_parse_captions_with_empty_line_after_index(self): captions = srt.parse( textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 Caption #1 2 00:00:07,000 --> 00:00:11,890 Caption #2 ''').strip().split('\n') ) self.assertEqual(len(captions), 2) self.assertEqual(captions[0].lines, ['Caption #1']) self.assertEqual(captions[1].lines, ['Caption #2']) self.assertEqual(captions[1].start, '00:00:07.000') def test_parse_captions_with_digits_only_line_at_the_end(self): # a number at the end is more likely to be text, such as a year, # than the index of a cue that is not there captions = srt.parse( textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 Copyright 2024 ''').strip().split('\n') ) self.assertEqual(len(captions), 1) self.assertEqual(captions[0].lines, ['Copyright', '2024']) def test_parse_captions_with_digits_only_line_followed_by_text(self): captions = srt.parse( textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 Caption #1 2 More text 3 00:00:07,000 --> 00:00:11,890 Caption #3 ''').strip().split('\n') ) self.assertEqual(len(captions), 2) self.assertEqual(captions[0].lines, ['Caption #1', '2', 'More text']) self.assertEqual(captions[1].lines, ['Caption #3']) def test_parse_captions_with_empty_line_after_cue_timings(self): captions = srt.parse( textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 Caption #1 2 00:00:07,000 --> 00:00:11,890 Caption #2 line 1 Caption #2 line 2 3 00:00:11,890 --> 00:00:16,320 Caption #3 ''').strip().split('\n') ) self.assertEqual(len(captions), 3) self.assertEqual( str(captions[0]), '00:00:00.500 00:00:07.000 Caption #1' ) self.assertEqual( str(captions[1]), r'00:00:07.000 00:00:11.890 Caption #2 line 1\n' 'Caption #2 line 2' ) self.assertEqual( str(captions[2]), '00:00:11.890 00:00:16.320 Caption #3' ) def test_parse_captions_with_whitespace_lines_in_text(self): captions = srt.parse( ['1', '00:00:00,500 --> 00:00:07,000', 'Caption #1 line 1', ' ', 'Caption #1 line 2' ] ) self.assertEqual(len(captions), 1) self.assertEqual( captions[0].lines, ['Caption #1 line 1', 'Caption #1 line 2'] ) def test_parse_skips_text_of_cue_with_invalid_timings(self): captions = srt.parse( textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 Caption #1 2 00:00:07,000 --> 00:00:11:890 Caption #2 More text of caption #2 3 00:00:11,890 --> 00:00:16,320 Caption #3 ''').strip().split('\n') ) self.assertEqual(len(captions), 2) self.assertEqual(captions[0].lines, ['Caption #1']) self.assertEqual(captions[1].lines, ['Caption #3']) def test_parse_captions_without_empty_line_between_cues(self): captions = srt.parse( textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 Caption #1 2 00:00:07,000 --> 00:00:11,890 Caption #2 ''').strip().split('\n') ) self.assertEqual(len(captions), 2) self.assertEqual(captions[0].lines, ['Caption #1']) self.assertEqual(captions[1].lines, ['Caption #2']) def test_parse_captions_with_empty_line_in_text_and_next_cue(self): captions = srt.parse( textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 Caption #1 line 1 Caption #1 line 2 2 00:00:07,000 --> 00:00:11,890 Caption #2 ''').strip().split('\n') ) self.assertEqual(len(captions), 2) self.assertEqual( captions[0].lines, ['Caption #1 line 1', 'Caption #1 line 2'] ) self.assertEqual(captions[1].lines, ['Caption #2']) def test_parse_captions_with_whitespace_around_index(self): captions = srt.parse( ['1', '00:00:00,500 --> 00:00:07,000', 'Caption #1', '', ' 2 ', '00:00:07,000 --> 00:00:11,890', 'Caption #2' ] ) self.assertEqual(len(captions), 2) self.assertEqual(captions[0].lines, ['Caption #1']) self.assertEqual(captions[1].lines, ['Caption #2']) def test_parse_captions_with_whitespace_around_first_index(self): captions = srt.parse( [' 1 ', '00:00:00,500 --> 00:00:07,000', 'Caption #1', '', '2', '00:00:07,000 --> 00:00:11,890', 'Caption #2' ] ) self.assertEqual(len(captions), 2) self.assertEqual(captions[0].lines, ['Caption #1']) self.assertEqual(captions[1].lines, ['Caption #2']) def test_parse_cue_without_index(self): captions = srt.parse( textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 Caption #1 00:00:07,000 --> 00:00:11,890 Caption #2 3 00:00:11,890 --> 00:00:16,320 Caption #3 ''').strip().split('\n') ) self.assertEqual( [caption.lines for caption in captions], [['Caption #1'], ['Caption #2'], ['Caption #3']] ) self.assertEqual(captions[1].start, '00:00:07.000') def test_parse_ignores_text_attached_to_end_timestamp(self): captions = srt.parse( ['1', '00:00:00,500 --> 00:00:07,000xyz', 'Caption #1'] ) self.assertEqual(len(captions), 1) self.assertEqual(captions[0].end, '00:00:07.000') def test_parse_timings_variants(self): for timings, start, end in ( ('00:00:00.500 --> 00:00:07.000', '00:00:00.500', '00:00:07.000'), ('00:00:00.500 --> 00:00:07,000', '00:00:00.500', '00:00:07.000'), ('0:0:0,500 --> 0:0:7,000', '00:00:00.500', '00:00:07.000'), ('100:00:00,500 --> 100:00:07,000', '100:00:00.500', '100:00:07.000'), ('00:61:00,500 --> 00:61:07,000', '01:01:00.500', '01:01:07.000'), ): captions = srt.parse(['1', timings, 'Caption #1']) self.assertEqual(len(captions), 1) self.assertEqual(captions[0].start, start) self.assertEqual(captions[0].end, end) def test_parse_skips_cue_with_incomplete_timings(self): for timings in ('00:00,500 --> 00:07,000', '00:00:00 --> 00:00:07'): self.assertEqual( srt.parse(['1', timings, 'Caption #1']), [] ) def test_parse_cue_with_too_many_digits(self): captions = srt.parse( ['1', '00:00:00,500 --> 00:00:07,000', 'Caption #1', '', '2', '00:00:07,000 --> 00:00:11,' + '9' * 5000, 'Caption #2'] ) self.assertEqual(len(captions), 1) self.assertEqual(captions[0].text, 'Caption #1') def test_parse_timings_with_leading_zeros(self): captions = srt.parse( ['1', '00:00:00,' + '0' * 30 + '500 --> 00:00:07,000', 'Caption #1'] ) self.assertEqual(len(captions), 1) self.assertEqual(captions[0].start, '00:00:00.500') def test_parse_skips_cue_with_too_many_digits_hours(self): captions = srt.parse( ['1', '00:00:00,500 --> 00:00:07,000', 'Caption #1', '', '2', '99999999999:00:07,000 --> 99999999999:00:11,890', 'Caption #2'] ) self.assertEqual(len(captions), 1) self.assertEqual(captions[0].text, 'Caption #1') def test_parse_skips_cues_without_text(self): captions = srt.parse( textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 2 00:00:07,000 --> 00:00:11,890 Caption #2 ''').strip().split('\n') ) self.assertEqual(len(captions), 1) self.assertEqual(captions[0].lines, ['Caption #2']) def test_write(self): out = io.StringIO() captions = [ Caption(start='00:00:00.500', end='00:00:07.000', text='Caption #1' ), Caption(start='00:00:07.000', end='00:00:11.890', text=['Caption #2 line 1', 'Caption #2 line 2' ] ) ] captions[0].comments.append('Comment for the first caption') captions[1].comments.append('Comment for the second caption') srt.write(out, captions) out.seek(0) self.assertEqual( out.read(), textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 Caption #1 2 00:00:07,000 --> 00:00:11,890 Caption #2 line 1 Caption #2 line 2 ''').strip() + '\n' ) def test_write_no_captions(self): out = io.StringIO() srt.write(out, []) self.assertEqual(out.getvalue(), '') def test_write_keeps_trailing_spaces_of_the_last_caption(self): out = io.StringIO() captions = [ Caption(start='00:00:01.000', end='00:00:02.000', text='a '), Caption(start='00:00:02.000', end='00:00:03.000', text='b ') ] srt.write(out, captions) self.assertEqual( out.getvalue(), '1\n00:00:01,000 --> 00:00:02,000\na \n\n' '2\n00:00:02,000 --> 00:00:03,000\nb \n' ) def test_write_skips_whitespace_lines(self): out = io.StringIO() captions = [ Caption(start='00:00:01.879', end='00:00:05.660', text=[' ', 'Caption #1'] ), Caption(start='00:00:05.660', end='00:00:05.670', text=['Caption #1', ' '] ) ] srt.write(out, captions) out.seek(0) self.assertEqual( out.read(), textwrap.dedent(''' 1 00:00:01,879 --> 00:00:05,660 Caption #1 2 00:00:05,660 --> 00:00:05,670 Caption #1 ''').strip() + '\n' ) def test_write_leaves_out_captions_without_text(self): captions = [ Caption('00:00:00.000', '00:00:01.000', ' '), Caption('00:00:01.000', '00:00:02.000', 'b'), Caption('00:00:02.000', '00:00:03.000', ''), Caption('00:00:03.000', '00:00:04.000', ['', ' ']), Caption('00:00:04.000', '00:00:05.000', 'c'), Caption('00:00:05.000', '00:00:06.000', ''), Caption('00:00:06.000', '00:00:07.000', [' ', '']), Caption('00:00:07.000', '00:00:08.000', ' '), ] for styles in (None, [], [Style('::cue(.red) { color: red; }')]): with self.subTest(styles=styles): out = io.StringIO() srt.write(out, captions, styles) self.assertEqual( out.getvalue(), '1\n00:00:01,000 --> 00:00:02,000\nb\n\n' '2\n00:00:04,000 --> 00:00:05,000\nc\n' ) def test_write_only_captions_without_text(self): out = io.StringIO() srt.write(out, [Caption('00:00:00.000', '00:00:01.000', ' ')]) self.assertEqual(out.getvalue(), '') def test_write_without_styles_removes_cue_tags(self): out = io.StringIO() captions = [ Caption(start='00:00:00.500', end='00:00:07.000', text='Caption #1' ) ] srt.write(out, captions) self.assertEqual( out.getvalue(), '1\n00:00:00,500 --> 00:00:07,000\nCaption #1\n' ) def test_write_with_styles_keeps_formatting(self): out = io.StringIO() captions = [ Caption(start='00:00:00.500', end='00:00:07.000', text=['Caption #1', 'line 2' ] ), Caption(start='00:00:07.000', end='00:00:11.890', text='Caption #2' ) ] styles = [Style('::cue(.yellow) {\n color: yellow;\n}')] srt.write(out, captions, styles) self.assertEqual( out.getvalue(), textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 Caption #1 line 2 2 00:00:07,000 --> 00:00:11,890 Caption #2 ''').strip() + '\n' ) def test_write_with_empty_styles_keeps_bold_italic_underline(self): out = io.StringIO() captions = [ Caption(start='00:00:00.500', end='00:00:07.000', text='bold italic underline' ) ] srt.write(out, captions, []) self.assertEqual( out.getvalue(), '1\n00:00:00,500 --> 00:00:07,000\n' 'bold italic underline\n' ) class TestCueColours(unittest.TestCase): def test_cue_colours(self): styles = [ Style(textwrap.dedent(''' ::cue { color: papayawhip; } ::cue(.magenta) { color: #ff00ff; } ::cue(.bg_black) { background-color: black; } ::cue(b) { color: peachpuff; } ''').strip()), Style('::cue(.blue), ::cue(.navy) { color: #0000ff; }') ] self.assertEqual( srt._cue_colours(styles), {'magenta': (2, '#ff00ff'), 'blue': (4, '#0000ff'), 'navy': (4, '#0000ff') } ) def test_cue_colours_ignores_comments(self): styles = [ Style(textwrap.dedent(''' /* ::cue(.red) { color: red; } */ ::cue(.green) { /* color: lime; */ color: green; } ''').strip()) ] self.assertEqual(srt._cue_colours(styles), {'green': (1, 'green')}) def test_cue_colours_later_rule_overrides(self): styles = [ Style('::cue(.title) { color: red; }'), Style('::cue(.title) { color: blue; }') ] self.assertEqual(srt._cue_colours(styles), {'title': (2, 'blue')}) def test_cue_colours_last_declaration_applies(self): styles = [Style('::cue(.title) { color: red; COLOR : blue }')] self.assertEqual(srt._cue_colours(styles), {'title': (1, 'blue')}) def test_cue_colours_removes_important(self): styles = [Style('::cue(.title) { color: red !important; }')] self.assertEqual(srt._cue_colours(styles), {'title': (1, 'red')}) def test_cue_colours_ignores_values_not_valid_in_font_tag(self): styles = [ Style('::cue(.a) { color: red; color: "blue" }'), Style('::cue(.b) { color: }') ] self.assertEqual(srt._cue_colours(styles), {'a': (1, 'red')}) def test_cue_colours_without_styles(self): self.assertEqual(srt._cue_colours([]), {}) class TestFormatCueText(unittest.TestCase): COLOURS = { 'magenta': (1, '#ff00ff'), 'blue': (2, '#0000ff') } def test_text_without_tags(self): self.assertEqual( srt._format_cue_text('2 < 3', self.COLOURS), '2 < 3' ) def test_nested_tags_with_colours(self): self.assertEqual( srt._format_cue_text( 'Some italic and normal', self.COLOURS ), 'Some italic' ' and normal' ) def test_class_of_later_rule_applies(self): self.assertEqual( srt._format_cue_text('text', self.COLOURS), 'text' ) def test_removes_tags_not_supported_by_srt(self): self.assertEqual( srt._format_cue_text( 'Hi there, ' '漢kan you', self.COLOURS ), 'Hi there, 漢kan you' ) def test_removes_timestamp_tags(self): self.assertEqual( srt._format_cue_text( 'Like a <00:19.000>big-a <00:00:19.500>pizza', self.COLOURS ), 'Like a big-a pizza' ) def test_closes_tags_left_open(self): self.assertEqual( srt._format_cue_text('open', self.COLOURS), 'open' ) def test_end_tag_closes_inner_tags(self): self.assertEqual( srt._format_cue_text('both neither', self.COLOURS), 'both neither' ) def test_ignores_end_tag_without_start_tag(self): self.assertEqual( srt._format_cue_text('bold', self.COLOURS), 'bold' ) def test_tags_across_lines(self): self.assertEqual( srt._format_cue_text('line 1\nline 2', self.COLOURS), 'line 1\nline 2' ) def test_tags_are_found_in_each_line(self): self.assertEqual( srt._format_cue_text('if a < b\nthen b > a', self.COLOURS), 'if a < b\nthen b > a' ) def test_keeps_font_tags(self): self.assertEqual( srt._format_cue_text( 'x y z', self.COLOURS ), 'x y z' ) def test_less_than_without_greater_than_is_text(self): self.assertEqual( srt._format_cue_text('bold 2 < 3 bold 2 < 3 00:00:07.000 Caption #1 ''').strip().split('\n')) ) self.assertTrue( vtt.WebVTTCueBlock.is_valid(textwrap.dedent(''' 00:00:00.500 --> 00:00:07.000 Caption #1 line 1 Caption #1 line 2 ''').strip().split('\n')) ) self.assertTrue( vtt.WebVTTCueBlock.is_valid(textwrap.dedent(''' identifier 00:00:00.500 --> 00:00:07.000 Caption #1 ''').strip().split('\n')) ) self.assertTrue( vtt.WebVTTCueBlock.is_valid(textwrap.dedent(''' identifier 00:00:00.500 --> 00:00:07.000 Caption #1 line 1 Caption #1 line 2 ''').strip().split('\n')) ) self.assertFalse( vtt.WebVTTCueBlock.is_valid(textwrap.dedent(''' 00:00:00.500 00:00:07.000 Caption #1 line 1 ''').strip().split('\n')) ) self.assertTrue( vtt.WebVTTCueBlock.is_valid(textwrap.dedent(''' 00:00:00.500 --> 00:00:07.000 ''').strip().split('\n')) ) self.assertTrue( vtt.WebVTTCueBlock.is_valid(textwrap.dedent(''' identifier 00:00:00.500 --> 00:00:07.000 ''').strip().split('\n')) ) for timings in ( '00:00:00,500 --> 00:00:07.000', '00:00:00:500 --> 00:00:07.000', '00:00:00.-500 --> 00:00:07.000', '00:00:00.500 --> 00:00:07,000', ): self.assertFalse( vtt.WebVTTCueBlock.is_valid([timings, 'Caption #1']) ) def test_is_valid_non_ascii_digits(self): lines = ['00:00:0\u0661.500 --> 00:00:07.000', 'Caption #1'] self.assertFalse(vtt.WebVTTCueBlock.is_valid(lines)) self.assertFalse(vtt.WebVTTCueBlock.is_valid(lines, strict=True)) def test_is_valid_non_padded_timestamps(self): lines = ['0:0:13.540 --> 0:0:17.85', 'Caption #1'] self.assertTrue(vtt.WebVTTCueBlock.is_valid(lines)) self.assertFalse(vtt.WebVTTCueBlock.is_valid(lines, strict=True)) self.assertTrue( vtt.WebVTTCueBlock.is_valid( ['00:13.540 --> 00:00:17.850', 'Caption #1'], strict=True ) ) def test_is_valid_strict_hours(self): for timings in ( '0:00:13.540 --> 00:00:17.850', '00:00:13.540 --> 0:00:17.850' ): self.assertTrue(vtt.WebVTTCueBlock.is_valid([timings])) self.assertFalse( vtt.WebVTTCueBlock.is_valid([timings], strict=True) ) self.assertTrue( vtt.WebVTTCueBlock.is_valid( ['100:00:13.540 --> 100:00:17.850'], strict=True ) ) def test_is_valid_strict_end_timestamp_boundary(self): for timings in ( '00:00:13.540 --> 00:00:17.8505', '00:00:13.540 --> 00:00:17.850abc' ): self.assertFalse( vtt.WebVTTCueBlock.is_valid([timings], strict=True) ) self.assertTrue( vtt.WebVTTCueBlock.is_valid( ['00:00:13.540 --> 00:00:17.850 align:start'], strict=True ) ) def test_from_lines(self): cue_block = vtt.WebVTTCueBlock.from_lines(textwrap.dedent(''' identifier 00:00:00.500 --> 00:00:07.000 Caption #1 line 1 Caption #1 line 2 ''').strip().split('\n') ) self.assertEqual(cue_block.identifier, 'identifier') self.assertEqual(cue_block.start, '00:00:00.500') self.assertEqual(cue_block.end, '00:00:07.000') self.assertListEqual( cue_block.payload, ['Caption #1 line 1', 'Caption #1 line 2'] ) class TestWebVTTCommentBlock(unittest.TestCase): def test_is_valid(self): self.assertTrue( vtt.WebVTTCommentBlock.is_valid(textwrap.dedent(''' NOTE This is a one line comment ''').strip().split('\n')) ) self.assertTrue( vtt.WebVTTCommentBlock.is_valid(textwrap.dedent(''' NOTE This is a another one line comment ''').strip().split('\n')) ) self.assertTrue( vtt.WebVTTCommentBlock.is_valid(textwrap.dedent(''' NOTE This is a multi-line comment taking two lines ''').strip().split('\n')) ) self.assertFalse( vtt.WebVTTCommentBlock.is_valid(textwrap.dedent(''' This is not a comment ''').strip().split('\n')) ) self.assertFalse( vtt.WebVTTCommentBlock.is_valid(textwrap.dedent(''' # This is not a comment ''').strip().split('\n')) ) self.assertFalse( vtt.WebVTTCommentBlock.is_valid(textwrap.dedent(''' // This is not a comment ''').strip().split('\n')) ) def test_is_valid_keyword(self): for line in ('NOTE', 'NOTE comment', 'NOTE\tcomment'): with self.subTest(line=line): self.assertTrue(vtt.WebVTTCommentBlock.is_valid([line])) for line in ('NOTEBOOK x', 'NOTE-x', 'NOTE\x0ccomment'): with self.subTest(line=line): self.assertFalse(vtt.WebVTTCommentBlock.is_valid([line])) def test_from_lines(self): comment = vtt.WebVTTCommentBlock.from_lines(textwrap.dedent(''' NOTE This is a one line comment ''').strip().split('\n') ) self.assertEqual(comment.text, 'This is a one line comment') comment = vtt.WebVTTCommentBlock.from_lines(textwrap.dedent(''' NOTE This is a multi-line comment taking two lines ''').strip().split('\n') ) self.assertEqual( comment.text, 'This is a multi-line comment\ntaking two lines' ) def test_format_lines(self): for text, lines in ( ('', ['NOTE ']), ('comment', ['NOTE comment']), ('comment\n', ['NOTE comment']), ('comment\u2028still comment', ['NOTE comment\u2028still comment']), ('line 1\rline 2\r\nline 3', ['NOTE', 'line 1', 'line 2', 'line 3']), ): with self.subTest(text=text): self.assertListEqual( vtt.WebVTTCommentBlock.format_lines(text), lines ) class TestWebVTTStyleBlock(unittest.TestCase): def test_is_valid(self): self.assertTrue( vtt.WebVTTStyleBlock.is_valid(textwrap.dedent(''' STYLE ::cue { background-image: linear-gradient(to bottom, dimgray, lightgray); color: papayawhip; } ''').strip().split('\n')) ) self.assertTrue( vtt.WebVTTStyleBlock.is_valid(textwrap.dedent(''' STYLE ::cue { background-image: linear-gradient(to bottom, dimgray, lightgray); color: papayawhip; } ::cue(b) { color: peachpuff; } ''').strip().split('\n')) ) self.assertFalse( vtt.WebVTTStyleBlock.is_valid(textwrap.dedent(''' STYLE ::cue { background-image: linear-gradient(to bottom, dimgray, lightgray); color: papayawhip; } ::cue(b) { color: peachpuff; } ''').strip().split('\n')) ) self.assertFalse( vtt.WebVTTStyleBlock.is_valid(textwrap.dedent(''' STYLE ::cue--> { background-image: linear-gradient(to bottom, dimgray, lightgray); color: papayawhip; } ''').strip().split('\n')) ) self.assertFalse( vtt.WebVTTStyleBlock.is_valid(textwrap.dedent(''' ::cue { background-image: linear-gradient(to bottom, dimgray, lightgray); color: papayawhip; } ''').strip().split('\n')) ) def test_from_lines(self): style = vtt.WebVTTStyleBlock.from_lines(textwrap.dedent(''' STYLE ::cue { background-image: linear-gradient(to bottom, dimgray, lightgray); color: papayawhip; } ::cue(b) { color: peachpuff; } ''').strip().split('\n') ) self.assertEqual( style.text, textwrap.dedent(''' ::cue { background-image: linear-gradient(to bottom, dimgray, lightgray); color: papayawhip; } ::cue(b) { color: peachpuff; } ''').strip() ) class TestWebVTTRegionBlock(unittest.TestCase): def test_is_valid(self): for lines in (['REGION', 'id:fred width:40%'], ['REGION', 'id:fred', 'width:40%'], ['REGION \t', 'id:fred'], ['REGION'], ): with self.subTest(lines=lines): self.assertTrue(vtt.WebVTTRegionBlock.is_valid(lines)) def test_is_not_valid(self): for lines in ([], ['REGIONS', 'id:fred'], ['REGION id:fred'], ['region', 'id:fred'], ['REGION\xa0', 'id:fred'], ['REGION', 'id:fred-->bill'], ['id:fred', 'REGION'], ): with self.subTest(lines=lines): self.assertFalse(vtt.WebVTTRegionBlock.is_valid(lines)) def test_format_lines(self): self.assertListEqual( vtt.WebVTTRegionBlock.format_lines( Region.from_string('id:fred width:40% foo') ), ['REGION', 'id:fred', 'width:40%', 'foo'] ) self.assertListEqual( vtt.WebVTTRegionBlock.format_lines(Region()), ['REGION'] ) class TestVTTModule(unittest.TestCase): def test_parse_invalid_format(self): self.assertRaises( MalformedFileError, vtt.parse, textwrap.dedent(''' 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:11.890 Caption text #2 ''').strip().split('\n') ) def test_parse_captions(self): output = vtt.parse( textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:11.890 Caption text #2 line 1 Caption text #2 line 2 ''').strip().split('\n') ) captions = output.captions styles = output.styles self.assertEqual(len(captions), 2) self.assertEqual(len(styles), 0) self.assertIsInstance(captions[0], Caption) self.assertIsInstance(captions[1], Caption) self.assertEqual( str(captions[0]), '00:00:00.500 00:00:07.000 Caption text #1' ) self.assertEqual( str(captions[1]), r'00:00:07.000 00:00:11.890 Caption text #2 line 1\n' 'Caption text #2 line 2' ) def test_parse_styles(self): output = vtt.parse( textwrap.dedent(''' WEBVTT STYLE ::cue { color: white; } STYLE ::cue(.important) { color: red; font-weight: bold; } 00:00:00.500 --> 00:00:07.000 Caption text #1 ''').strip().split('\n') ) captions = output.captions styles = output.styles self.assertEqual(len(captions), 1) self.assertEqual(len(styles), 2) self.assertIsInstance(styles[0], Style) self.assertIsInstance(styles[1], Style) self.assertEqual( str(styles[0].text), textwrap.dedent(''' ::cue { color: white; } ''').strip() ) self.assertEqual( str(styles[1].text), textwrap.dedent(''' ::cue(.important) { color: red; font-weight: bold; } ''').strip(), ) def test_parse_content(self): output = vtt.parse( textwrap.dedent(''' WEBVTT NOTE This is a testing sample NOTE We can see two header comments, a style comment and finally a footer comments STYLE ::cue { background-image: linear-gradient(to bottom, dimgray); color: papayawhip; } NOTE the following style needs review STYLE ::cue { color: white; } NOTE Comment for the first caption 00:00:00.500 --> 00:00:07.000 Caption text #1 NOTE Comment for the second caption that is very long 00:00:07.000 --> 00:00:11.890 Caption text #2 line 1 Caption text #2 line 2 NOTE Copyright 2024 NOTE end of file ''').strip().split('\n') ) captions = output.captions styles = output.styles self.assertEqual(len(captions), 2) self.assertEqual(len(styles), 2) self.assertIsInstance(captions[0], Caption) self.assertIsInstance(captions[1], Caption) self.assertIsInstance(styles[0], Style) self.assertIsInstance(styles[1], Style) self.assertEqual( str(captions[0]), '00:00:00.500 00:00:07.000 Caption text #1' ) self.assertEqual( str(captions[1]), r'00:00:07.000 00:00:11.890 Caption text #2 line 1\n' 'Caption text #2 line 2' ) self.assertEqual( str(styles[0].text), textwrap.dedent(''' ::cue { background-image: linear-gradient(to bottom, dimgray); color: papayawhip; } ''').strip() ) self.assertEqual( str(styles[1].text), textwrap.dedent(''' ::cue { color: white; } ''').strip() ) self.assertEqual( styles[0].comments, [] ) self.assertEqual( styles[1].comments, ['the following style needs review'] ) self.assertEqual( captions[0].comments, ['Comment for the first caption'] ) self.assertEqual( captions[1].comments, ['Comment for the second caption\nthat is very long'] ) self.assertListEqual(output.header_comments, ['This is a testing sample', 'We can see two header comments, a style\n' 'comment and finally a footer comments' ] ) self.assertListEqual(output.footer_comments, ['Copyright 2024', 'end of file' ] ) def test_to_str_rejects_text_not_read_back(self): def caption(text='Caption', identifier=None): return Caption('00:00:00.500', '00:00:07.000', text, identifier) def with_comment(item, comment): item.comments.append(comment) return item for name, items, message in ( ('empty line in caption text', {'captions': [caption('line 1\n\nline 2')]}, 'Caption text must not have empty lines'), ('empty first line in caption text', {'captions': [caption(['', 'line 2'])]}, 'Caption text must not have empty lines'), ('arrow in caption text', {'captions': [caption('a --> b')]}, 'Caption text must not have empty lines or "-->"'), ('cue timings in caption text', {'captions': [caption('a\n00:00:08.000 --> 00:00:09.000')]}, 'Caption text must not have empty lines or "-->"'), ('line break in identifier', {'captions': [caption(identifier='id\nmore')]}, 'Caption identifiers must be a single line'), ('arrow in identifier', {'captions': [caption(identifier='a --> b')]}, 'Caption identifiers must be a single line without "-->"'), ('empty line in style', {'styles': [Style('::cue {\n\n color: red;\n}')]}, 'Style text must not have empty lines'), ('arrow in style', {'styles': [Style('::cue { content: "-->"; }')]}, 'Style text must not have empty lines or "-->"'), ('empty line in header comment', {'header_comments': ['line 1\n\nline 2']}, 'Comments must not have empty lines'), ('arrow after first line of footer comment', {'footer_comments': ['line 1\na --> b']}, 'Comments must not have empty lines or "-->"'), ('arrow in single line header comment', {'header_comments': ['a --> b']}, 'Comments must not have empty lines or "-->"'), ('cue timings in first line of comment', {'captions': [with_comment( caption(), '00:00:05.000 --> 00:00:06.000\nreview this' )]}, 'Comments must not have empty lines or "-->"'), ('cue timings in single line comment', {'footer_comments': ['00:00:05.000 --> 00:00:06.000']}, 'Comments must not have empty lines or "-->"'), ('arrow in header text', {'header_text': 'a --> b'}, 'Header text must be a single line without "-->"'), ('empty line in caption comment', {'captions': [with_comment(caption(), 'a\n\nb')]}, 'Comments must not have empty lines'), ('empty line in style comment', {'styles': [with_comment(Style('::cue {}'), 'a\n\nb')]}, 'Comments must not have empty lines'), ('empty line in region comment', {'regions': [with_comment(Region(id='r'), 'a\n\nb')]}, 'Comments must not have empty lines')): with self.subTest(name): arguments = {'captions': [], 'styles': [], 'header_comments': [], 'footer_comments': [], **items} with self.assertRaises(ValueError) as cm: vtt.to_str(**arguments) self.assertIn(message, str(cm.exception)) def test_to_str_text_read_back(self): captions = [ Caption('00:00:00.500', '00:00:01.000', 'line 1\n \nline 2', ' '), Caption('00:00:01.000', '00:00:02.000', ''), Caption('00:00:02.000', '00:00:03.000', 'a\n', 'id'), # empty lines at the end only end the cue Caption('00:00:03.000', '00:00:04.000', ['b', '', '']) ] captions[1].comments.append('first line\nsecond line') styles = [Style('::cue {\n color: red;\n}\n\n')] output = vtt.parse( vtt.to_str(captions, styles, ['header', 'NOTE in a comment'], ['footer\n\n'] ).split('\n') ) self.assertEqual( [(c.identifier, c.raw_text, c.comments) for c in output.captions], [(' ', 'line 1\n \nline 2', []), (None, '', ['first line\nsecond line']), ('id', 'a', []), (None, 'b', [])] ) self.assertEqual([s.text for s in output.styles], ['::cue {\n color: red;\n}']) self.assertEqual(output.header_comments, ['header', 'NOTE in a comment']) self.assertEqual(output.footer_comments, ['footer']) def test_write(self): out = io.StringIO() captions = [ Caption(start='00:00:00.500', end='00:00:07.000', text='Caption #1' ), Caption(start='00:00:07.000', end='00:00:11.890', text=['Caption #2 line 1', 'Caption #2 line 2' ] ) ] styles = [ Style('::cue(b) {\n color: peachpuff;\n}'), Style('::cue {\n color: papayawhip;\n}') ] captions[0].comments.append('Comment for the first caption') captions[1].comments.append('Comment for the second caption') styles[1].comments.append( 'Comment for the second style\nwith two lines' ) header_comments = ['header comment', 'begin of the file'] footer_comments = ['footer comment', 'end of file'] vtt.write( out, captions, styles, header_comments, footer_comments ) out.seek(0) self.assertEqual( out.read(), textwrap.dedent(''' WEBVTT NOTE header comment NOTE begin of the file STYLE ::cue(b) { color: peachpuff; } NOTE Comment for the second style with two lines STYLE ::cue { color: papayawhip; } NOTE Comment for the first caption 00:00:00.500 --> 00:00:07.000 Caption #1 NOTE Comment for the second caption 00:00:07.000 --> 00:00:11.890 Caption #2 line 1 Caption #2 line 2 NOTE footer comment NOTE end of file ''').strip() ) def test_to_str(self): captions = [ Caption(start='00:00:00.500', end='00:00:07.000', text='Caption #1' ), Caption(start='00:00:07.000', end='00:00:11.890', text=['Caption #2 line 1', 'Caption #2 line 2' ] ) ] styles = [ Style('::cue(b) {\n color: peachpuff;\n}'), Style('::cue {\n color: papayawhip;\n}') ] captions[0].comments.append('Comment for the first caption') captions[1].comments.append('Comment for the second caption') styles[1].comments.append( 'Comment for the second style\nwith two lines' ) header_comments = ['header comment', 'begin of the file'] footer_comments = ['footer comment', 'end of file'] self.assertEqual( vtt.to_str( captions, styles, header_comments, footer_comments ), textwrap.dedent(''' WEBVTT NOTE header comment NOTE begin of the file STYLE ::cue(b) { color: peachpuff; } NOTE Comment for the second style with two lines STYLE ::cue { color: papayawhip; } NOTE Comment for the first caption 00:00:00.500 --> 00:00:07.000 Caption #1 NOTE Comment for the second caption 00:00:07.000 --> 00:00:11.890 Caption #2 line 1 Caption #2 line 2 NOTE footer comment NOTE end of file ''').strip() ) def test_parse_ignores_malformed_cue_block(self): output = vtt.parse( textwrap.dedent(''' WEBVTT 00:00:00.-004 --> 00:00:04.300 Caption text #1 00:00:04.300 --> 00:00:07.000 Caption text #2 ''').strip().split('\n') ) self.assertEqual(len(output.captions), 1) self.assertEqual(output.captions[0].text, 'Caption text #2') def test_parse_non_padded_timestamps(self): output = vtt.parse( textwrap.dedent(''' WEBVTT 0:0:0.0 --> 0:0:3.540 Caption text #1 0:0:3.540 --> 0:1:5.2 Caption text #2 ''').strip().split('\n') ) self.assertEqual(len(output.captions), 2) self.assertEqual(output.captions[0].start, '00:00:00.000') self.assertEqual(output.captions[0].end, '00:00:03.540') self.assertEqual(output.captions[1].start, '00:00:03.540') self.assertEqual(output.captions[1].end, '00:01:05.200') def test_parse_strict_non_padded_timestamps(self): with self.assertRaises(MalformedCaptionError) as cm: vtt.parse( textwrap.dedent(''' WEBVTT 00:00:00.000 --> 00:00:03.540 Caption text #1 0:0:3.540 --> 0:0:7.230 Caption text #2 ''').strip().split('\n'), strict=True ) self.assertIn('0:0:3.540 --> 0:0:7.230', str(cm.exception)) def test_parse_ignores_out_of_range_timestamp(self): output = vtt.parse( textwrap.dedent(''' WEBVTT 00:00:61.000 --> 00:00:62.000 Caption text #1 00:00:04.300 --> 00:00:07.000 Caption text #2 ''').strip().split('\n') ) self.assertEqual(len(output.captions), 1) self.assertEqual(output.captions[0].text, 'Caption text #2') def test_parse_strict_out_of_range_timestamp(self): with self.assertRaises(MalformedCaptionError) as cm: vtt.parse( textwrap.dedent(''' WEBVTT 00:00:61.000 --> 00:00:62.000 Caption text #1 ''').strip().split('\n'), strict=True ) self.assertIn('00:00:61.000', str(cm.exception)) def test_parse_ignores_too_many_digits_hours(self): output = vtt.parse( ['WEBVTT', '', '1' * 5000 + ':00:00.000 --> 00:00:01.000', 'Caption text #1', '', '00:00:04.300 --> 00:00:07.000', 'Caption text #2'] ) self.assertEqual(len(output.captions), 1) self.assertEqual(output.captions[0].text, 'Caption text #2') def test_parse_strict_too_many_digits_hours(self): with self.assertRaises(MalformedCaptionError): vtt.parse( ['WEBVTT', '', '1' * 5000 + ':00:00.000 --> 00:00:01.000', 'Caption text'], strict=True ) def test_parse_strict_malformed_cue_block(self): with self.assertRaises(MalformedCaptionError) as cm: vtt.parse( textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:04.300 Caption text #1 00:00:04.300 --> 00:00:07.-004 Caption text #2 ''').strip().split('\n'), strict=True ) self.assertIn('00:00:04.300 --> 00:00:07.-004', str(cm.exception)) def test_parse_strict_valid_content(self): output = vtt.parse( textwrap.dedent(''' WEBVTT NOTE a comment STYLE ::cue { color: papayawhip; } 00:00:00.500 --> 00:00:07.000 Caption text #1 ''').strip().split('\n'), strict=True ) self.assertEqual(len(output.captions), 1) self.assertEqual(len(output.styles), 1) def test_parse_whitespace_line_is_cue_text(self): output = vtt.parse([ 'WEBVTT', '', '00:00:01.879 --> 00:00:05.660 align:start position:0%', ' ', 'are you done with your chunk yet almost', '', '00:00:05.660 --> 00:00:05.670 align:start position:0%', 'are you done with your chunk yet almost', ' ', ]) self.assertEqual(len(output.captions), 2) self.assertEqual(output.captions[0].start, '00:00:01.879') self.assertListEqual( output.captions[0].lines, [' ', 'are you done with your chunk yet almost'] ) self.assertListEqual( output.captions[1].lines, ['are you done with your chunk yet almost', ' '] ) def test_parse_empty_line_ends_cue(self): output = vtt.parse( textwrap.dedent(''' WEBVTT 00:02:36.420 --> 00:02:42.699 congratulations ''').strip().split('\n') ) self.assertEqual(len(output.captions), 1) self.assertEqual(output.captions[0].start, '00:02:36.420') self.assertEqual(output.captions[0].text, '') def test_parse_cue_without_text(self): output = vtt.parse( textwrap.dedent(''' WEBVTT identifier 00:00:00.500 --> 00:00:04.300 00:00:04.300 --> 00:00:07.000 Caption text #2 ''').strip().split('\n'), strict=True ) self.assertEqual(len(output.captions), 2) self.assertEqual(output.captions[0].identifier, 'identifier') self.assertEqual(output.captions[0].text, '') self.assertEqual(output.captions[1].text, 'Caption text #2') def test_parse_cue_timings_line_starts_new_block(self): output = vtt.parse( textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:04.300 Caption text #1 00:00:04.300 --> 00:00:07.000 Caption text #2 ''').strip().split('\n') ) self.assertEqual(len(output.captions), 2) self.assertIsNone(output.captions[0].identifier) self.assertEqual(output.captions[0].end, '00:00:04.300') self.assertEqual(output.captions[0].text, 'Caption text #1') self.assertEqual(output.captions[1].start, '00:00:04.300') self.assertEqual(output.captions[1].text, 'Caption text #2') def test_parse_cue_without_text_followed_by_cue_timings_line(self): output = vtt.parse( textwrap.dedent(''' WEBVTT 00:22:21.320 --> 00:22:26.520 00:21:13.720 --> 00:21:15.360 line:90% position:50% align:middle Caption text #2 ''').strip().split('\n') ) self.assertEqual(len(output.captions), 2) self.assertEqual(output.captions[0].start, '00:22:21.320') self.assertEqual(output.captions[0].text, '') self.assertEqual(output.captions[1].start, '00:21:13.720') self.assertEqual(output.captions[1].end, '00:21:15.360') self.assertEqual(output.captions[1].text, 'Caption text #2') def test_parse_strict_cue_without_text_followed_by_cue_timings_line(self): with self.assertRaises(MalformedCaptionError) as cm: vtt.parse( textwrap.dedent(''' WEBVTT 00:22:21.320 --> 00:22:26.520 00:21:13.720 --> 00:21:15.360 Caption text #2 ''').strip().split('\n'), strict=True ) self.assertIn('00:21:13.720 --> 00:21:15.360', str(cm.exception)) def test_parse_consecutive_cues_without_text(self): output = vtt.parse( textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:04.300 00:00:04.300 --> 00:00:07.000 00:00:07.000 --> 00:00:09.000 Caption text #3 ''').strip().split('\n') ) self.assertEqual(len(output.captions), 3) self.assertListEqual( [caption.text for caption in output.captions], ['', '', 'Caption text #3'] ) def test_parse_cue_settings(self): output = vtt.parse( textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:04.300 line:0 align:start Caption text #1 identifier 00:00:04.300 --> 00:00:07.000\tposition:10%,line-left size:50% Caption text #2 00:00:07.000 --> 00:00:09.000 Caption text #3 ''').strip().split('\n') ) self.assertEqual(len(output.captions), 3) self.assertEqual(output.captions[0].settings.line, '0') self.assertEqual(output.captions[0].settings.align, 'start') self.assertEqual(output.captions[0].text, 'Caption text #1') self.assertEqual(output.captions[1].identifier, 'identifier') self.assertEqual( str(output.captions[1].settings), 'position:10%,line-left size:50%' ) self.assertFalse(output.captions[2].settings) def test_parse_keeps_invalid_and_unknown_cue_settings(self): output = vtt.parse( textwrap.dedent(''' WEBVTT 00:21:13.720 --> 00:21:15.360 line:90% position:50% align:middle Caption text #1 00:21:15.360 --> 00:21:17.000 foo:bar line:0 line:-1 Caption text #2 ''').strip().split('\n') ) self.assertEqual( str(output.captions[0].settings), 'line:90% position:50% align:middle' ) self.assertIsNone(output.captions[0].settings.align) self.assertEqual( str(output.captions[1].settings), 'foo:bar line:0 line:-1' ) self.assertEqual(output.captions[1].settings.line, '-1') def test_parse_ignores_text_attached_to_end_timestamp(self): output = vtt.parse( textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:04.3001 line:0 Caption text #1 ''').strip().split('\n') ) self.assertEqual(output.captions[0].end, '00:00:04.300') self.assertFalse(output.captions[0].settings) def test_parse_cue_settings_without_space(self): output = vtt.parse([ 'WEBVTT', '', '00:00:00.500 --> 00:00:04.300line:0', 'Caption text #1', ]) self.assertEqual(output.captions[0].end, '00:00:04.300') self.assertEqual(str(output.captions[0].settings), 'line:0') def test_parse_cue_settings_after_non_ascii_space(self): output = vtt.parse([ 'WEBVTT', '', '00:00:00.500 --> 00:00:04.300\xa0align:start', 'Caption text #1', ]) self.assertIsNone(output.captions[0].settings.align) self.assertEqual( str(output.captions[0].settings), '\xa0align:start' ) def test_parse_strict_cue_settings_after_non_ascii_space(self): with self.assertRaises(MalformedCaptionError): vtt.parse( [ 'WEBVTT', '', '00:00:00.500 --> 00:00:04.300\xa0align:start', 'Caption text #1', ], strict=True ) def test_parse_strict_cue_settings(self): output = vtt.parse( textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:04.300 line:0 align:start Caption text #1 ''').strip().split('\n'), strict=True ) self.assertEqual( str(output.captions[0].settings), 'line:0 align:start' ) def test_parse_strict_invalid_cue_settings(self): for settings in ('align:middle', 'foo:bar', 'line:0 line:1'): with self.subTest(settings=settings): with self.assertRaises(MalformedCaptionError): vtt.parse( [ 'WEBVTT', '', f'00:00:00.500 --> 00:00:04.300 {settings}', 'Caption text #1', ], strict=True ) def test_format_timings(self): caption = Caption('00:00:00.500', '00:00:04.300', 'Caption text') self.assertEqual( vtt._format_timings(caption), '00:00:00.500 --> 00:00:04.300' ) caption.settings.line = '0' caption.settings.align = 'start' self.assertEqual( vtt._format_timings(caption), '00:00:00.500 --> 00:00:04.300 line:0 align:start' ) def test_cue_settings_round_trip(self): content = textwrap.dedent(''' WEBVTT 00:21:13.720 --> 00:21:15.360 line:90% position:50% align:middle Caption text #1 identifier 00:21:15.360 --> 00:21:17.000 foo:bar line:0 Caption text #2 00:21:17.000 --> 00:21:19.000 Caption text #3 ''').lstrip() output = vtt.parse(content.splitlines()) self.assertEqual( vtt.to_str( output.captions, output.styles, output.header_comments, output.footer_comments ), content ) def test_parse_whitespace_lines_between_cues(self): output = vtt.parse([ 'WEBVTT', '', '00:00:00.500 --> 00:00:04.300', 'Caption text #1', ' ', '00:00:04.300 --> 00:00:07.000', 'Caption text #2', ]) self.assertEqual(len(output.captions), 2) self.assertListEqual( output.captions[0].lines, ['Caption text #1', ' '] ) self.assertEqual(output.captions[1].start, '00:00:04.300') self.assertEqual(output.captions[1].text, 'Caption text #2') def test_parse_strict_cue_timings_line_without_empty_line(self): with self.assertRaises(MalformedCaptionError) as cm: vtt.parse( textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:04.300 Caption text #1 00:00:04.300 --> 00:00:07.000 Caption text #2 ''').strip().split('\n'), strict=True ) self.assertIn('00:00:04.300 --> 00:00:07.000', str(cm.exception)) def test_parse_regions(self): output = vtt.parse( textwrap.dedent(''' WEBVTT REGION id:fred width:40% lines:3 regionanchor:0%,100% viewportanchor:10%,90% scroll:up REGION id:bill 00:00:00.500 --> 00:00:04.300 region:fred Caption text #1 ''').strip().split('\n') ) self.assertEqual(len(output.regions), 2) self.assertEqual(output.regions[0].id, 'fred') self.assertEqual(output.regions[0].width, '40%') self.assertEqual(output.regions[0].lines, '3') self.assertEqual(output.regions[0].regionanchor, '0%,100%') self.assertEqual(output.regions[0].viewportanchor, '10%,90%') self.assertEqual(output.regions[0].scroll, 'up') self.assertEqual(output.regions[1].id, 'bill') self.assertEqual(len(output.captions), 1) self.assertEqual(output.captions[0].settings.region, 'fred') def test_parse_regions_and_styles(self): output = vtt.parse( textwrap.dedent(''' WEBVTT STYLE ::cue { color: red; } REGION id:fred 00:00:00.500 --> 00:00:04.300 region:fred Caption text #1 ''').strip().split('\n') ) self.assertEqual(len(output.styles), 1) self.assertEqual(len(output.regions), 1) self.assertEqual(len(output.captions), 1) def test_parse_keeps_invalid_and_unknown_region_settings(self): output = vtt.parse( textwrap.dedent(''' WEBVTT REGION id:fred scroll:down foo:bar ''').strip().split('\n') ) self.assertIsNone(output.regions[0].scroll) self.assertListEqual( list(output.regions[0]), ['id:fred', 'scroll:down', 'foo:bar'] ) def test_parse_region_comments(self): output = vtt.parse( textwrap.dedent(''' WEBVTT NOTE header comment REGION id:fred NOTE comment for bill REGION id:bill 00:00:00.500 --> 00:00:04.300 Caption text #1 ''').strip().split('\n') ) self.assertListEqual(output.header_comments, ['header comment']) self.assertListEqual(output.regions[0].comments, []) self.assertListEqual(output.regions[1].comments, ['comment for bill']) def test_parse_ignores_regions_after_a_cue(self): output = vtt.parse( textwrap.dedent(''' WEBVTT REGION id:fred 00:00:00.500 --> 00:00:04.300 Caption text #1 REGION id:bill ''').strip().split('\n') ) self.assertListEqual( [region.id for region in output.regions], ['fred'] ) self.assertEqual(len(output.captions), 1) def test_parse_strict_region_after_a_cue(self): with self.assertRaises(MalformedRegionError) as cm: vtt.parse( textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:04.300 Caption text #1 REGION id:fred ''').strip().split('\n'), strict=True ) self.assertIn('Region block after a cue', str(cm.exception)) def test_parse_ignores_regions_after_a_malformed_cue(self): output = vtt.parse( textwrap.dedent(''' WEBVTT 00:00:00.-004 --> 00:00:04.300 Caption text #1 REGION id:fred ''').strip().split('\n') ) self.assertListEqual(output.regions, []) self.assertListEqual(output.captions, []) def test_parse_strict_regions(self): output = vtt.parse( textwrap.dedent(''' WEBVTT REGION id:fred width:40% ''').strip().split('\n'), strict=True ) self.assertEqual(output.regions[0].width, '40%') def test_parse_strict_invalid_region_settings(self): for settings in ('id:fred scroll:down', 'id:fred foo:bar', 'id:fred id:bill', 'id:fred width'): with self.subTest(settings=settings): with self.assertRaises(MalformedRegionError) as cm: vtt.parse( ['WEBVTT', '', 'REGION', settings], strict=True ) self.assertIn('region setting', str(cm.exception)) def test_parse_strict_region_line_with_cue_timings(self): with self.assertRaises(MalformedCaptionError): vtt.parse( ['WEBVTT', '', 'REGION', '0:0:0.5 --> 0:0:4.3', 'text'], strict=True ) def test_parse_strict_invalid_blocks(self): cue = ['00:00:00.500 --> 00:00:04.300', 'Caption text'] for block in (['stray text'], ['NOTEbook'], ['NOTE', 'a --> b'], ['NOTE a --> b'], ['STYLE'], ['STYLES', '::cue {}']): for lines in ([*block, '', *cue], [*cue, '', *block, '', *cue], [*cue, '', *block]): with self.subTest(lines=lines): with self.assertRaises(MalformedCaptionError) as cm: vtt.parse(['WEBVTT', '', *lines], strict=True) self.assertIn(repr(block), str(cm.exception)) def test_parse_ignores_invalid_blocks(self): cue = ['00:00:00.500 --> 00:00:04.300', 'Caption text'] for block in (['stray text'], ['NOTEbook'], ['NOTE', 'a --> b'], ['NOTE a --> b'], ['STYLE'], ['STYLES', '::cue {}']): with self.subTest(block=block): output = vtt.parse(['WEBVTT', '', *block, '', *cue]) self.assertEqual(len(output.captions), 1) self.assertListEqual(output.styles, []) self.assertListEqual(output.header_comments, []) def test_parse_strict_text_block_between_cues(self): with self.assertRaises(MalformedCaptionError) as cm: vtt.parse( textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:04.300 Caption text #1 stray text block 00:00:05.000 --> 00:00:07.000 Caption text #2 ''').strip().split('\n'), strict=True ) self.assertIn("Invalid block ['stray text block']", str(cm.exception)) def test_parse_strict_valid_blocks(self): output = vtt.parse( textwrap.dedent(''' WEBVTT - Sample title X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:126000 NOTE NOTE\tcomment after a tab NOTE comment on the next line STYLE \t ::cue { color: papayawhip; } 00:00:00.500 --> 00:00:04.300 Caption text #1 ''').strip().split('\n'), strict=True ) self.assertEqual(len(output.captions), 1) self.assertEqual(len(output.styles), 1) self.assertListEqual( output.header_comments, ['', 'comment after a tab', 'comment on the next line'] ) def test_parse_header_keyword(self): cue = ['', '00:00:00.500 --> 00:00:04.300', 'Caption text'] for line, header_text in (('WEBVTT', ''), ('WEBVTT ', ''), ('WEBVTT - title', '- title'), ('WEBVTT\ttitle', 'title')): for strict in (False, True): with self.subTest(line=line, strict=strict): output = vtt.parse([line, *cue], strict=strict) self.assertEqual(output.header_text, header_text) self.assertEqual(len(output.captions), 1) def test_parse_header_keyword_followed_by_text(self): cue = ['', '00:00:00.500 --> 00:00:04.300', 'Caption text'] for line in ('WEBVTTX', 'WEBVTT-title', 'WEBVTT\x0ctitle'): with self.subTest(line=line): output = vtt.parse([line, *cue]) self.assertEqual(len(output.captions), 1) def test_parse_strict_header_keyword_followed_by_text(self): cue = ['', '00:00:00.500 --> 00:00:04.300', 'Caption text'] for line in ('WEBVTTX', 'WEBVTT-title', 'WEBVTT\x0ctitle'): with self.subTest(line=line): with self.assertRaises(MalformedFileError) as cm: vtt.parse([line, *cue], strict=True) self.assertIn(repr(line), str(cm.exception)) def test_parse_strict_header_with_cue_timings_arrow(self): with self.assertRaises(MalformedCaptionError) as cm: vtt.parse( ['WEBVTT a --> b', '', '00:00:00.500 --> 00:00:04.300', 'Caption text'], strict=True ) self.assertIn("Invalid cue block ['WEBVTT a --> b']", str(cm.exception)) def test_parse_ignores_comments_with_cue_timings_arrow(self): for block in (['NOTE a --> b'], ['NOTE', 'a --> b'], ['NOTE 00:00:05.000 --> 00:00:06.000']): with self.subTest(block=block): output = vtt.parse( ['WEBVTT', '', 'NOTE kept', '', *block, '', '00:00:00.500 --> 00:00:04.300', 'Caption text', '', *block] ) self.assertListEqual(output.header_comments, ['kept']) self.assertListEqual(output.captions[0].comments, []) self.assertListEqual(output.footer_comments, []) def test_parse_header_text_with_cue_timings_arrow(self): output = vtt.parse( ['WEBVTT a --> b', 'Kind: captions', '', 'REGION', 'id:fred', '', '00:00:00.500 --> 00:00:04.300', 'Caption text'] ) self.assertEqual(output.header_text, '') self.assertListEqual(output.metadata, ['Kind: captions']) self.assertListEqual([region.id for region in output.regions], ['fred']) self.assertEqual(len(output.captions), 1) def test_parse_and_write_back_cue_timings_arrows(self): output = vtt.parse( ['WEBVTT a --> b', '', 'NOTE a --> b', '', '00:00:00.500 --> 00:00:04.300', 'Caption text'] ) content = vtt.to_str( output.captions, output.styles, output.header_comments, output.footer_comments, output.header_text, output.metadata, output.regions ) self.assertEqual( content, 'WEBVTT\n\n00:00:00.500 --> 00:00:04.300\nCaption text\n' ) def test_parse_style_with_whitespace_after_style(self): output = vtt.parse( ['WEBVTT', '', 'STYLE \t', '::cue { color: papayawhip; }', '', '00:00:00.500 --> 00:00:04.300', 'Caption text'] ) self.assertListEqual( [style.text for style in output.styles], ['::cue { color: papayawhip; }'] ) def test_parse_ignores_styles_after_a_cue(self): output = vtt.parse( ['WEBVTT', '', 'STYLE', '::cue { color: peachpuff; }', '', '00:00:00.500 --> 00:00:04.300', 'Caption text', '', 'STYLE', '::cue { color: papayawhip; }'] ) self.assertListEqual( [style.text for style in output.styles], ['::cue { color: peachpuff; }'] ) self.assertEqual(len(output.captions), 1) def test_parse_strict_style_after_a_cue(self): with self.assertRaises(MalformedCaptionError) as cm: vtt.parse( ['WEBVTT', '', '00:00:00.500 --> 00:00:04.300', 'Caption text', '', 'STYLE', '::cue { color: papayawhip; }'], strict=True ) self.assertIn('Style block after a cue', str(cm.exception)) def test_parse_end_time_not_after_start_time(self): for timings in ('00:00:05.000 --> 00:00:02.000', '00:00:02.000 --> 00:00:02.000'): with self.subTest(timings=timings): output = vtt.parse(['WEBVTT', '', timings, 'Caption text']) self.assertEqual(len(output.captions), 1) def test_parse_strict_end_time_not_after_start_time(self): for timings in ('00:00:05.000 --> 00:00:02.000', '00:00:02.000 --> 00:00:02.000', '01:00:00.000 --> 00:59:59.999'): with self.subTest(timings=timings): with self.assertRaises(MalformedCaptionError) as cm: vtt.parse( ['WEBVTT', '', timings, 'Caption text'], strict=True ) self.assertIn('not after the start time', str(cm.exception)) self.assertIn(timings, str(cm.exception)) def test_parse_strict_end_time_just_after_start_time(self): output = vtt.parse( ['WEBVTT', '', '00:00:02.000 --> 00:00:02.001', 'Caption text'], strict=True ) self.assertEqual(len(output.captions), 1) def test_to_str_regions(self): output = vtt.to_str( [Caption('00:00:00.500', '00:00:04.300', 'Caption text', settings='region:fred')], [], ['header comment'], [], regions=[Region(id='fred', width='40%'), Region(id='bill')] ) self.assertEqual( output, textwrap.dedent(''' WEBVTT NOTE header comment REGION id:fred width:40% REGION id:bill 00:00:00.500 --> 00:00:04.300 region:fred Caption text ''').lstrip() ) def test_regions_round_trip(self): content = textwrap.dedent(''' WEBVTT NOTE header comment REGION id:fred width:40% scroll:down NOTE comment for bill REGION id:bill STYLE ::cue { color: red; } 00:00:00.500 --> 00:00:04.300 region:fred Caption text #1 ''').lstrip() output = vtt.parse(content.splitlines()) self.assertEqual( vtt.to_str( output.captions, output.styles, output.header_comments, output.footer_comments, regions=output.regions ), content ) def test_write_regions(self): f = io.StringIO() vtt.write( f, [Caption('00:00:00.500', '00:00:04.300', 'Caption text')], [], [], [], regions=[Region(id='fred')] ) self.assertEqual( f.getvalue(), 'WEBVTT\n\nREGION\nid:fred\n\n' '00:00:00.500 --> 00:00:04.300\nCaption text\n' ) def test_parse_style_with_whitespace_line(self): output = vtt.parse([ 'WEBVTT', '', 'STYLE', '::cue {', ' color: papayawhip;', '}', ' ', '::cue(b) {', ' color: peachpuff;', '}', '', '00:00:00.500 --> 00:00:07.000', 'Caption text #1', ]) self.assertEqual(len(output.styles), 1) self.assertIn('peachpuff', output.styles[0].text) def test_parse_header(self): output = vtt.parse( textwrap.dedent(''' WEBVTT - Sample title X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:126000 Kind: captions NOTE header comment 00:00:00.500 --> 00:00:07.000 Caption text #1 ''').strip().split('\n') ) self.assertEqual(output.header_text, '- Sample title') self.assertListEqual( output.metadata, ['X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:126000', 'Kind: captions' ] ) self.assertListEqual(output.header_comments, ['header comment']) self.assertEqual(len(output.captions), 1) def test_parse_no_header(self): output = vtt.parse( textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 ''').strip().split('\n') ) self.assertEqual(output.header_text, '') self.assertListEqual(output.metadata, []) def test_parse_header_text_separated_by_tab(self): output = vtt.parse(['WEBVTT\t Sample title']) self.assertEqual(output.header_text, ' Sample title') def test_parse_metadata_followed_by_cue(self): output = vtt.parse( textwrap.dedent(''' WEBVTT X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:126000 00:00:00.500 --> 00:00:07.000 Caption text #1 ''').strip().split('\n'), strict=True ) self.assertListEqual( output.metadata, ['X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:126000'] ) self.assertEqual(len(output.captions), 1) def test_write_header(self): out = io.StringIO() captions = [ Caption(start='00:00:00.500', end='00:00:07.000', text='Caption #1' ) ] vtt.write( out, captions, [], ['header comment'], [], '- Sample title', ['X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:126000', 'Kind: captions' ] ) out.seek(0) self.assertEqual( out.read(), textwrap.dedent(''' WEBVTT - Sample title X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:126000 Kind: captions NOTE header comment 00:00:00.500 --> 00:00:07.000 Caption #1 ''').lstrip() ) def test_to_str_header_round_trip(self): lines = textwrap.dedent(''' WEBVTT\tSample title X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:126000 indented line 00:00:00.500 --> 00:00:07.000 Caption text #1 ''').lstrip().split('\n') output = vtt.parse(lines) content = vtt.to_str( output.captions, output.styles, output.header_comments, output.footer_comments, output.header_text, output.metadata ) self.assertEqual(content, '\n'.join(lines).replace('\t', ' ')) self.assertEqual(vtt.parse(content.split('\n')), output) def test_to_str_invalid_header_text(self): for header_text in ('title\nmore', 'title\r', 'title\r\nmore', 'a --> b'): with self.subTest(header_text=header_text): with self.assertRaises(ValueError): vtt.to_str([], [], [], [], header_text) def test_to_str_header_with_other_unicode_line_separators(self): content = vtt.to_str( [], [], [], [], 'title\u2028more', ['Kind: a\x0cb', 'Lang\x85en'] ) self.assertEqual( content, 'WEBVTT title\u2028more\nKind: a\x0cb\nLang\x85en\n' ) def test_parse_items_without_header(self): output = vtt.parse_items(['00:00:00.500 --> 00:00:07.000', 'Caption text #1' ]) self.assertEqual(output.header_text, '') self.assertListEqual(output.metadata, []) self.assertEqual(len(output.captions), 1) def test_parse_items_strict_without_header(self): output = vtt.parse_items(['00:00:00.500 --> 00:00:07.000', 'Caption text #1' ], strict=True ) self.assertEqual(len(output.captions), 1) def test_parse_items_strict_invalid_first_block(self): with self.assertRaises(MalformedCaptionError) as cm: vtt.parse_items(['stray text', '', '00:00:00.500 --> 00:00:07.000', 'Caption text #1' ], strict=True ) self.assertIn("Invalid block ['stray text']", str(cm.exception)) def test_to_str_invalid_metadata(self): for line in ('', 'Kind: captions\nLanguage: en', 'Language: en\r', 'X-TIMESTAMP-MAP --> 0', ): with self.subTest(line=line): with self.assertRaises(ValueError) as cm: vtt.to_str([], [], [], [], '', ['Kind: captions', line]) self.assertIn(repr(line), str(cm.exception)) glut23-webvtt-py-cca74e4/tests/test_webvtt.py000066400000000000000000003036361527171777000214040ustar00rootroot00000000000000import codecs import datetime import fractions import unittest import os import io import textwrap import warnings import tempfile import shutil import pathlib import webvtt from webvtt.models import Caption, Region, Style, Timestamp from webvtt.utils import CODEC_BOMS from webvtt.errors import ( MalformedFileError, MalformedCaptionError, MissingFilenameError ) from .helpers import named_temporary_file PATH_TO_SAMPLES = pathlib.Path(__file__).resolve().parent / 'samples' class TestWebVTT(unittest.TestCase): def test_from_string(self): vtt = webvtt.from_string(textwrap.dedent(""" WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:11.890 Caption text #2 line 1 Caption text #2 line 2 00:00:11.890 --> 00:00:16.320 Caption text #3 00:00:16.320 --> 00:00:21.580 Caption text #4 """).strip() ) self.assertEqual(len(vtt), 4) self.assertEqual( str(vtt[0]), '00:00:00.500 00:00:07.000 Caption text #1' ) self.assertEqual( str(vtt[1]), r'00:00:07.000 00:00:11.890 Caption text #2 line 1\n' 'Caption text #2 line 2' ) self.assertEqual( str(vtt[2]), '00:00:11.890 00:00:16.320 Caption text #3' ) self.assertEqual( str(vtt[3]), '00:00:16.320 00:00:21.580 Caption text #4' ) def test_write_captions(self): out = io.StringIO() vtt = webvtt.read(PATH_TO_SAMPLES / 'one_caption.vtt') new_caption = Caption(start='00:00:07.000', end='00:00:11.890', text=['New caption text line1', 'New caption text line2' ] ) vtt.captions.append(new_caption) vtt.write(out) out.seek(0) self.assertEqual( out.read(), textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:11.890 New caption text line1 New caption text line2 ''').strip() + '\n' ) def test_write_captions_in_srt(self): out = io.StringIO() vtt = webvtt.read(PATH_TO_SAMPLES / 'one_caption.vtt') new_caption = Caption(start='00:00:07.000', end='00:00:11.890', text=['New caption text line1', 'New caption text line2' ] ) vtt.captions.append(new_caption) vtt.write(out, format='srt') out.seek(0) self.assertEqual( out.read(), textwrap.dedent(''' 1 00:00:00,500 --> 00:00:07,000 Caption text #1 2 00:00:07,000 --> 00:00:11,890 New caption text line1 New caption text line2 ''').strip() + '\n' ) def test_write_captions_in_srt_no_cuetags(self): """https://github.com/glut23/webvtt-py/issues/56""" out = io.StringIO() vtt = webvtt.read(PATH_TO_SAMPLES / 'cue_tags.vtt') vtt.write(out, format='srt') out.seek(0) self.assertEqual( out.read(), textwrap.dedent(''' 1 00:00:16,500 --> 00:00:18,500 When the moon hits your eye 2 00:00:18,500 --> 00:00:20,500 Like a big-a pizza pie 3 00:00:20,500 --> 00:00:21,500 That's amore ''').strip() + '\n' ) def test_write_captions_in_unsupported_format(self): self.assertRaises( ValueError, webvtt.WebVTT().write, io.StringIO(), format='ttt' ) def test_save_captions(self): with named_temporary_file('w', suffix='.vtt') as f: f.write((PATH_TO_SAMPLES / 'one_caption.vtt').read_text()) f.flush() vtt = webvtt.read(f.name) new_caption = Caption(start='00:00:07.000', end='00:00:11.890', text=['New caption text line1', 'New caption text line2' ] ) vtt.captions.append(new_caption) vtt.save() f.flush() self.assertEqual( pathlib.Path(f.name).read_text(), textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:11.890 New caption text line1 New caption text line2 ''').strip() + '\n' ) def test_srt_conversion(self): with tempfile.TemporaryDirectory() as td: with open(pathlib.Path(td) / 'one_caption.srt', 'w') as f: f.write((PATH_TO_SAMPLES / 'one_caption.srt').read_text()) webvtt.from_srt( pathlib.Path(td) / 'one_caption.srt' ).save() self.assertTrue( os.path.exists(pathlib.Path(td) / 'one_caption.vtt') ) self.assertEqual( (pathlib.Path(td) / 'one_caption.vtt').read_text(), textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 ''').strip() + '\n' ) def test_sbv_conversion(self): with tempfile.TemporaryDirectory() as td: with open(pathlib.Path(td) / 'two_captions.sbv', 'w') as f: f.write( (PATH_TO_SAMPLES / 'two_captions.sbv').read_text() ) webvtt.from_sbv( pathlib.Path(td) / 'two_captions.sbv' ).save() self.assertTrue( os.path.exists(pathlib.Path(td) / 'two_captions.vtt') ) self.assertEqual( (pathlib.Path(td) / 'two_captions.vtt').read_text(), textwrap.dedent(''' WEBVTT 00:00:00.378 --> 00:00:11.378 Caption text #1 00:00:11.378 --> 00:00:12.305 Caption text #2 (line 1) Caption text #2 (line 2) ''').strip() + '\n' ) def test_save_to_other_location(self): with tempfile.TemporaryDirectory() as td: webvtt.read( PATH_TO_SAMPLES / 'one_caption.vtt' ).save(td) self.assertTrue( os.path.exists(pathlib.Path(td) / 'one_caption.vtt') ) def test_save_specific_filename(self): with tempfile.TemporaryDirectory() as td: webvtt.read( PATH_TO_SAMPLES / 'one_caption.vtt' ).save( pathlib.Path(td) / 'one_caption_new.vtt' ) self.assertTrue( os.path.exists(pathlib.Path(td) / 'one_caption_new.vtt') ) def test_save_specific_filename_no_extension(self): with tempfile.TemporaryDirectory() as td: webvtt.read( PATH_TO_SAMPLES / 'one_caption.vtt' ).save( pathlib.Path(td) / 'one_caption_new' ) self.assertTrue( os.path.exists(pathlib.Path(td) / 'one_caption_new.vtt') ) def test_save_specific_filename_other_extension(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'one_caption.vtt') for name in ('captions.webvtt', 'captions.txt', 'captions.VTT'): with self.subTest(name=name), \ tempfile.TemporaryDirectory() as td: output = pathlib.Path(td) / name vtt.save(output) self.assertEqual(os.listdir(td), [name]) self.assertEqual(vtt.file, str(output)) def test_save_as_srt_extension(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'one_caption.vtt') for name, saved in (('captions', 'captions.srt'), ('captions.txt', 'captions.txt')): with self.subTest(name=name), \ tempfile.TemporaryDirectory() as td: vtt.save_as_srt(pathlib.Path(td) / name) self.assertEqual(os.listdir(td), [saved]) def test_save_without_output_keeps_format_extension(self): content = (PATH_TO_SAMPLES / 'one_caption.vtt').read_text() for name in ('captions.vtt', 'captions.webvtt', 'captions.VTT'): with self.subTest(name=name), \ tempfile.TemporaryDirectory() as td: path = os.path.join(td, name) pathlib.Path(path).write_text(content) vtt = webvtt.read(path) vtt.save() self.assertEqual(os.listdir(td), [name]) self.assertEqual(vtt.file, path) def test_save_without_output_converted_file(self): for sample, name, read, saved in ( ('sample.srt', 'in.srt', webvtt.from_srt, 'in.vtt'), ('sample.sbv', 'in.sbv', webvtt.from_sbv, 'in.vtt'), ('sample.vtt', 'in.txt', webvtt.read, 'in.vtt')): with self.subTest(name=name), \ tempfile.TemporaryDirectory() as td: path = os.path.join(td, name) shutil.copy(PATH_TO_SAMPLES / sample, path) vtt = read(path) vtt.save() self.assertEqual(sorted(os.listdir(td)), sorted({name, saved})) self.assertEqual(vtt.file, os.path.join(td, saved)) def test_save_as_srt_keeps_srt_encoding(self): srt = '1\n00:00:00,500 --> 00:00:07,000\nCafé\n' with tempfile.TemporaryDirectory() as td: path = pathlib.Path(td, 'captions.srt') path.write_bytes(srt.encode('cp1252')) for vtt in (webvtt.from_srt(path, encoding='cp1252'), webvtt.from_buffer( io.BytesIO(srt.encode('cp1252')), format='srt', encoding='cp1252' )): with self.subTest(file=vtt.file): output = pathlib.Path(td, 'output.srt') vtt.save_as_srt(output) self.assertIn('Café'.encode('cp1252'), output.read_bytes()) # an encoding given when saving is used instead vtt.save_as_srt(output, encoding='utf-8') self.assertIn('Café'.encode('utf-8'), output.read_bytes()) # WebVTT is saved in UTF-8 vtt.save(pathlib.Path(td, 'output.vtt')) self.assertIn( 'Café'.encode('utf-8'), pathlib.Path(td, 'output.vtt').read_bytes() ) def test_save_as_srt_default_encoding(self): srt = '1\n00:00:00,500 --> 00:00:07,000\nCafé\n' sbv = '0:00:00.500,0:00:07.000\nCafé\n' vtt = 'WEBVTT\n\n00:00:00.500 --> 00:00:07.000\nCafé\n' with tempfile.TemporaryDirectory() as td: def write(name, data): path = pathlib.Path(td, name) path.write_bytes(data) return path for captions in ( webvtt.read( write('latin-1.vtt', vtt.encode('latin-1')), encoding='latin-1' ), webvtt.from_sbv( write('latin-1.sbv', sbv.encode('latin-1')), encoding='latin-1' ), # the Byte Order Mark is used instead of the encoding webvtt.from_srt( write('bom.srt', codecs.BOM_UTF8 + srt.encode()), encoding='latin-1' ), # text is already decoded webvtt.from_buffer(io.StringIO(srt), format='srt', encoding='latin-1'), webvtt.from_srt(write('utf-8.srt', srt.encode()))): with self.subTest(file=captions.file): output = pathlib.Path(td, 'output.srt') captions.save_as_srt(output) self.assertIn('Café'.encode('utf-8'), output.read_bytes()) def test_save_without_encoding_after_reading_with_encoding(self): with tempfile.TemporaryDirectory() as td: path = pathlib.Path(td, 'captions.vtt') path.write_bytes( 'WEBVTT\n\n00:00:00.500 --> 00:00:07.000\nCafé\n' .encode('latin-1') ) vtt = webvtt.read(path, encoding='latin-1') vtt.save() self.assertIn('Café'.encode('utf-8'), path.read_bytes()) def test_save_as_srt_captions_without_text_round_trip(self): vtt = webvtt.WebVTT(captions=[ Caption('00:00:00.000', '00:00:01.000', ' '), Caption('00:00:01.000', '00:00:02.000', 'b'), Caption('00:00:02.000', '00:00:03.000', ['c', 'd']), Caption('00:00:03.000', '00:00:04.000', ''), ]) for keep_formatting in (False, True): with self.subTest(keep_formatting=keep_formatting), \ tempfile.TemporaryDirectory() as td: path = os.path.join(td, 'out.srt') vtt.save_as_srt(path, keep_formatting=keep_formatting) self.assertEqual( [(c.start, c.end, c.lines) for c in webvtt.from_srt(path).captions], [('00:00:01.000', '00:00:02.000', ['b']), ('00:00:02.000', '00:00:03.000', ['c', 'd'])] ) self.assertTrue( pathlib.Path(path).read_text().startswith('1\n') ) def test_save_as_srt_without_output(self): for sample, name, read, saved in ( ('sample.vtt', 'in.vtt', webvtt.read, 'in.srt'), ('sample.vtt', 'in.webvtt', webvtt.read, 'in.srt'), ('sample.srt', 'in.SRT', webvtt.from_srt, 'in.SRT')): with self.subTest(name=name), \ tempfile.TemporaryDirectory() as td: path = os.path.join(td, name) shutil.copy(PATH_TO_SAMPLES / sample, path) vtt = read(path) vtt.save_as_srt() self.assertEqual(sorted(os.listdir(td)), sorted({name, saved})) self.assertEqual(vtt.file, os.path.join(td, saved)) def test_save_to_folder_keeps_format_extension(self): with tempfile.TemporaryDirectory() as td: path = os.path.join(td, 'captions.webvtt') shutil.copy(PATH_TO_SAMPLES / 'sample.vtt', path) vtt = webvtt.read(path) folder = os.path.join(td, 'folder') os.mkdir(folder) vtt.save(folder) vtt.file = path vtt.save_as_srt(folder) self.assertEqual(sorted(os.listdir(folder)), ['captions.srt', 'captions.webvtt']) def test_from_buffer(self): with open(PATH_TO_SAMPLES / 'sample.vtt', 'r', encoding='utf-8') as f: vtt = webvtt.from_buffer(f) self.assertEqual(len(vtt), 16) self.assertEqual( str(vtt), textwrap.dedent(''' 00:00:00.500 00:00:07.000 Caption text #1 00:00:07.000 00:00:11.890 Caption text #2 00:00:11.890 00:00:16.320 Caption text #3 00:00:16.320 00:00:21.580 Caption text #4 00:00:21.580 00:00:23.880 Caption text #5 00:00:23.880 00:00:27.280 Caption text #6 00:00:27.280 00:00:30.280 Caption text #7 00:00:30.280 00:00:36.510 Caption text #8 00:00:36.510 00:00:38.870 Caption text #9 00:00:38.870 00:00:45.000 Caption text #10 00:00:45.000 00:00:47.000 Caption text #11 00:00:47.000 00:00:50.970 Caption text #12 00:00:50.970 00:00:54.440 Caption text #13 00:00:54.440 00:00:58.600 Caption text #14 00:00:58.600 00:01:01.350 Caption text #15 00:01:01.350 00:01:04.300 Caption text #16 ''' ).strip()) def test_deprecated_read_buffer(self): with open(PATH_TO_SAMPLES / 'sample.vtt', 'r', encoding='utf-8') as f: with warnings.catch_warnings(record=True) as warns: warnings.simplefilter('always') vtt = webvtt.read_buffer(f) self.assertIsInstance(vtt.captions, list) self.assertEqual(len(warns), 1) self.assertIs(warns[0].category, DeprecationWarning) self.assertEqual( 'Deprecated: use from_buffer instead.', str(warns[0].message) ) # the warning points to the code calling read_buffer self.assertEqual(os.path.basename(warns[0].filename), os.path.basename(__file__)) def test_read_memory_buffer(self): buffer = io.StringIO( (PATH_TO_SAMPLES / 'sample.vtt').read_text() ) self.assertIsInstance( webvtt.from_buffer(buffer).captions, list ) def test_read_memory_buffer_carriage_return(self): """https://github.com/glut23/webvtt-py/issues/29""" buffer = io.StringIO(textwrap.dedent('''\ WEBVTT\r \r 00:00:00.500 --> 00:00:07.000\r Caption text #1\r \r 00:00:07.000 --> 00:00:11.890\r Caption text #2\r \r 00:00:11.890 --> 00:00:16.320\r Caption text #3\r ''') ) self.assertEqual( len(webvtt.from_buffer(buffer).captions), 3 ) def test_read_malformed_buffer(self): malformed_payloads = ['', 'MOCK MALFORMED CONTENT'] for payload in malformed_payloads: buffer = io.StringIO(payload) with self.assertRaises(MalformedFileError): webvtt.from_buffer(buffer) def test_from_buffer_strict(self): content = textwrap.dedent(''' WEBVTT 00:00:00.-004 --> 00:00:04.300 Caption text #1 ''').strip() self.assertEqual( len(webvtt.from_buffer(io.StringIO(content)).captions), 0 ) with self.assertRaises(MalformedCaptionError): webvtt.from_buffer(io.StringIO(content), strict=True) def test_from_buffer_strict_srt_and_sbv(self): for format, sample in (('srt', 'sample.srt'), ('sbv', 'sample.sbv')): with self.subTest(format=format), \ open(PATH_TO_SAMPLES / sample, encoding='utf-8') as f: with self.assertRaises(ValueError) as cm: webvtt.from_buffer(f, format=format, strict=True) self.assertEqual( str(cm.exception), f'Strict parsing is not supported for the {format} ' 'format' ) # the buffer is not read self.assertEqual(f.tell(), 0) self.assertTrue( webvtt.from_buffer(f, format=format, strict=False) .captions ) def test_from_buffer_unsupported_format(self): path = PATH_TO_SAMPLES / 'sample.vtt' for strict in (False, True): with self.subTest(strict=strict), \ open(path, encoding='utf-8') as f: with self.assertRaises(ValueError) as cm: webvtt.from_buffer(f, format='ass', strict=strict) self.assertEqual(str(cm.exception), 'Format ass is not supported.') self.assertEqual(f.tell(), 0) def test_from_string_strict(self): """https://github.com/glut23/webvtt-py/issues/76""" content = textwrap.dedent(''' WEBVTT 00:00:00.-004 --> 00:00:04.300 This is a test with a malformed timestamp. ''').strip() self.assertEqual(len(webvtt.from_string(content).captions), 0) with self.assertRaises(MalformedCaptionError): webvtt.from_string(content, strict=True) def test_from_string_strict_header_keyword_followed_by_text(self): content = 'WEBVTTX\n\n00:00:00.500 --> 00:00:04.300\nCaption text\n' self.assertEqual(len(webvtt.from_string(content).captions), 1) with self.assertRaises(MalformedFileError): webvtt.from_string(content, strict=True) def test_from_string_line_breaks(self): content = ( 'WEBVTT\r\n' '\r\n' '00:00:00.500 --> 00:00:07.000\r' 'line 1\u2028still line 1\x0cand\x85more\r' 'line 2\n' '\n' '00:00:07.000 --> 00:00:11.890\n' 'Caption #2' ) vtt = webvtt.from_string(content) self.assertListEqual( [caption.lines for caption in vtt.captions], [['line 1\u2028still line 1\x0cand\x85more', 'line 2'], ['Caption #2'] ] ) with tempfile.TemporaryDirectory() as td: path = os.path.join(td, 'captions.vtt') with open(path, 'w', encoding='utf-8', newline='') as f: f.write(content) self.assertListEqual( [caption.lines for caption in webvtt.read(path).captions], [caption.lines for caption in vtt.captions] ) def test_read_buffer_for_vtt_content(self): buffer = io.StringIO(textwrap.dedent('''\ WEBVTT\r \r 00:00:00.500 --> 00:00:07.000\r Caption text #1\r \r 00:00:07.000 --> 00:00:11.890\r Caption text #2\r \r 00:00:11.890 --> 00:00:16.320\r Caption text #3\r ''') ) vtt = webvtt.from_buffer(buffer, format='vtt') self.assertEqual(len(vtt), 3) self.assertEqual( str(vtt[0]), '00:00:00.500 00:00:07.000 Caption text #1' ) self.assertEqual( str(vtt[1]), '00:00:07.000 00:00:11.890 Caption text #2' ) self.assertEqual( str(vtt[2]), '00:00:11.890 00:00:16.320 Caption text #3' ) def test_read_buffer_for_srt_content(self): buffer = io.StringIO(textwrap.dedent('''\ 0\r 00:00:00,500 --> 00:00:07,000\r Caption text #1\r \r 1\r 00:00:07,000 --> 00:00:11,890\r Caption text #2\r \r 2\r 00:00:11,890 --> 00:00:16,320\r Caption text #3\r ''') ) vtt = webvtt.from_buffer(buffer, format='srt') self.assertEqual(len(vtt), 3) self.assertEqual( str(vtt[0]), '00:00:00.500 00:00:07.000 Caption text #1' ) self.assertEqual( str(vtt[1]), '00:00:07.000 00:00:11.890 Caption text #2' ) self.assertEqual( str(vtt[2]), '00:00:11.890 00:00:16.320 Caption text #3' ) def test_read_buffer_for_sbv_content_with_long_hours(self): buffer = io.StringIO('100:00:00.000,100:00:05.000\nCaption text\n') vtt = webvtt.from_buffer(buffer, format='sbv') self.assertEqual(len(vtt), 1) self.assertEqual(vtt[0].start_time.hours, 100) self.assertEqual(vtt[0].end, '100:00:05.000') def test_read_buffer_for_sbv_content(self): buffer = io.StringIO(textwrap.dedent('''\ 00:00:00.500,00:00:07.000\r Caption text #1\r \r 00:00:07.000,00:00:11.890\r Caption text #2\r \r 00:00:11.890,00:00:16.320\r Caption text #3\r ''') ) vtt = webvtt.from_buffer(buffer, format='sbv') self.assertEqual(len(vtt), 3) self.assertEqual( str(vtt[0]), '00:00:00.500 00:00:07.000 Caption text #1' ) self.assertEqual( str(vtt[1]), '00:00:07.000 00:00:11.890 Caption text #2' ) self.assertEqual( str(vtt[2]), '00:00:11.890 00:00:16.320 Caption text #3' ) def test_read_buffer_with_bom(self): for format in ('vtt', 'srt', 'sbv'): source = (PATH_TO_SAMPLES / ('sample.' + format)).read_text() expected = webvtt.from_buffer(io.StringIO(source), format=format) for buffer in ( io.StringIO('\ufeff' + source), io.BytesIO(('\ufeff' + source).encode('utf-8')), iter(('\ufeff' + source).splitlines()) ): with self.subTest(format=format, buffer=type(buffer)): actual = webvtt.from_buffer(buffer, format=format) self.assertEqual( [(c.start, c.end, c.text) for c in actual], [(c.start, c.end, c.text) for c in expected] ) def test_from_buffer_binary_file(self): for format in ('vtt', 'srt', 'sbv'): sample = PATH_TO_SAMPLES / ('sample.' + format) expected = webvtt.from_buffer( io.StringIO(sample.read_text()), format=format ) with self.subTest(format=format), open(sample, 'rb') as f: actual = webvtt.from_buffer(f, format=format) self.assertEqual( [(c.start, c.end, c.text) for c in actual], [(c.start, c.end, c.text) for c in expected] ) self.assertEqual(actual.file, str(sample)) self.assertFalse(f.closed) def test_from_buffer_name_that_is_not_a_path(self): sample = PATH_TO_SAMPLES / 'sample.vtt' stdin = io.BytesIO(sample.read_bytes()) stdin.name = '' flags = os.O_RDONLY | getattr(os, 'O_BINARY', 0) with open(os.open(sample, flags), 'rb') as from_descriptor: for buffer in (stdin, from_descriptor, io.BytesIO(sample.read_bytes()), io.StringIO(sample.read_text())): with self.subTest(name=getattr(buffer, 'name', None)): vtt = webvtt.from_buffer(buffer) self.assertIsNone(vtt.file) self.assertTrue(vtt.captions) with self.assertRaises(MissingFilenameError): vtt.save() with tempfile.TemporaryDirectory() as td, \ self.assertRaises(MissingFilenameError): vtt.save(td) def test_from_buffer_name_as_bytes_or_path(self): sample = PATH_TO_SAMPLES / 'sample.vtt' with open(os.fsencode(sample), 'rb') as f: self.assertEqual(webvtt.from_buffer(f).file, str(sample)) buffer = io.BytesIO(sample.read_bytes()) buffer.name = sample self.assertEqual(webvtt.from_buffer(buffer).file, str(sample)) @unittest.skipIf(os.name == 'nt', 'Windows file names cannot have angle brackets') def test_read_file_named_in_angle_brackets(self): cwd = os.getcwd() with tempfile.TemporaryDirectory() as td: os.chdir(td) try: shutil.copy(PATH_TO_SAMPLES / 'sample.vtt', '') vtt = webvtt.read('') self.assertEqual(vtt.file, '') vtt.save() self.assertEqual(sorted(os.listdir(td)), ['', '.vtt']) finally: os.chdir(cwd) def test_from_buffer_binary_with_bom(self): content = 'WEBVTT\n\n00:00:00.500 --> 00:00:07.000\nCaption ñ\n' for encoding in CODEC_BOMS: with self.subTest(encoding=encoding): vtt = webvtt.from_buffer(io.BytesIO( CODEC_BOMS[encoding] + content.encode(encoding) )) self.assertEqual(vtt[0].text, 'Caption ñ') self.assertEqual(vtt.encoding, encoding) self.assertEqual(vtt.content, content) def test_from_buffer_binary_with_bom_saves_bom(self): content = 'WEBVTT\n\n00:00:00.500 --> 00:00:07.000\nCaption ñ\n' vtt = webvtt.from_buffer(io.BytesIO( codecs.BOM_UTF16_LE + content.encode('utf-16-le') )) with tempfile.TemporaryDirectory() as td: output = os.path.join(td, 'captions.vtt') vtt.save(output) self.assertTrue( pathlib.Path(output).read_bytes().startswith( codecs.BOM_UTF16_LE ) ) def test_from_buffer_binary_encoding(self): content = 'WEBVTT\n\n00:00:00.500 --> 00:00:07.000\nCaption ñ\n' buffer = io.BytesIO(content.encode('latin-1')) vtt = webvtt.from_buffer(buffer, encoding='latin-1') self.assertEqual(vtt[0].text, 'Caption ñ') self.assertEqual(vtt.encoding, webvtt.webvtt.DEFAULT_ENCODING) buffer.seek(0) with self.assertRaises(UnicodeDecodeError): webvtt.from_buffer(buffer) def test_from_buffer_bom_before_encoding(self): content = 'WEBVTT\n\n00:00:00.500 --> 00:00:07.000\nCaption ñ\n' vtt = webvtt.from_buffer( io.BytesIO(codecs.BOM_UTF8 + content.encode('utf-8')), encoding='latin-1' ) self.assertEqual(vtt[0].text, 'Caption ñ') self.assertEqual(vtt.encoding, 'utf-8') def test_from_buffer_string(self): content = 'WEBVTT\n\n00:00:00.500 --> 00:00:07.000\nCaption\n' for buffer, hint in ( (content, 'use from_string'), (content.encode('utf-8'), 'use io.BytesIO'), (bytearray(content.encode('utf-8')), 'use io.BytesIO') ): with self.subTest(buffer=type(buffer)): with self.assertRaises(TypeError) as cm: webvtt.from_buffer(buffer) self.assertIn(hint, str(cm.exception)) def test_get_lines_preserves_bom_in_caption_text(self): self.assertEqual( webvtt.WebVTT._get_lines(iter(['\ufeffWEBVTT\r\n', '\ufeffcaption\n'])), ['WEBVTT', '\ufeffcaption'] ) def test_read_buffer_unsupported_format(self): self.assertRaises( ValueError, webvtt.from_buffer, io.StringIO(), format='ttt' ) def test_read_bytesio_buffer_line_breaks(self): sources = { 'vtt': 'WEBVTT\r\r00:00:00.500 --> 00:00:07.000\r' 'line 1\u2028still line 1\r\nline 2\n', 'srt': '1\r00:00:00,500 --> 00:00:07,000\r' 'line 1\u2028still line 1\r\nline 2\n', 'sbv': '0:00:00.500,0:00:07.000\r' 'line 1\u2028still line 1\r\nline 2\n', } for format, source in sources.items(): with self.subTest(format=format): buffer = io.BytesIO(source.encode('utf-8')) vtt = webvtt.from_buffer(buffer, format=format) self.assertEqual(len(vtt), 1) self.assertListEqual( vtt[0].lines, ['line 1\u2028still line 1', 'line 2'] ) self.assertFalse(buffer.closed) def test_read_buffer_line_breaks(self): content = ( 'WEBVTT\r\r00:00:00.500 --> 00:00:07.000\r' 'line 1\u2028still line 1\r\nline 2\r\r\n' '00:00:07.000 --> 00:00:11.890\nCaption #2\n' ) vtt = webvtt.from_buffer(io.StringIO(content)) self.assertListEqual( [caption.lines for caption in vtt.captions], [['line 1\u2028still line 1', 'line 2'], ['Caption #2']] ) def test_save_as_srt_keeps_unicode_line_separators(self): vtt = webvtt.from_string(textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:07.000 line 1\u2028still line 1 line 2 ''').lstrip() ) out = io.StringIO() vtt.write(out, format='srt') self.assertEqual( out.getvalue(), '1\n00:00:00,500 --> 00:00:07,000\n' 'line 1\u2028still line 1\nline 2\n' ) def test_read_bytesio_buffer_for_srt_content(self): buffer = io.BytesIO(textwrap.dedent('''\ 0\r 00:00:00,500 --> 00:00:07,000\r Caption text #1\r \r 1\r 00:00:07,000 --> 00:00:11,890\r Caption text #2\r \r 2\r 00:00:11,890 --> 00:00:16,320\r Caption text #3\r ''').encode('utf-8') ) vtt = webvtt.from_buffer(buffer, format='srt') self.assertEqual(len(vtt), 3) self.assertEqual( str(vtt[0]), '00:00:00.500 00:00:07.000 Caption text #1' ) self.assertEqual( str(vtt[1]), '00:00:07.000 00:00:11.890 Caption text #2' ) self.assertEqual( str(vtt[2]), '00:00:11.890 00:00:16.320 Caption text #3' ) def test_captions(self): captions = webvtt.read(PATH_TO_SAMPLES / 'sample.vtt').captions self.assertIsInstance( captions, list ) self.assertEqual(len(captions), 16) def test_sequence_iteration(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'sample.vtt') self.assertIsInstance(vtt[0], Caption) self.assertEqual(len(vtt), len(vtt.captions)) def test_save_no_filename(self): self.assertRaises( webvtt.errors.MissingFilenameError, webvtt.WebVTT().save ) def test_list_arguments_are_kept(self): for name, item in (('captions', Caption()), ('styles', Style('::cue {}')), ('header_comments', 'comment'), ('footer_comments', 'comment'), ('metadata', 'Kind: captions'), ('regions', Region(id='fred'))): for items in ([], [item]): with self.subTest(name=name, items=items): vtt = webvtt.WebVTT(**{name: items}) items.append(item) self.assertIs(getattr(vtt, name), items) def test_list_arguments_default_to_new_lists(self): first, second = webvtt.WebVTT(), webvtt.WebVTT() for name in ('captions', 'styles', 'header_comments', 'footer_comments', 'metadata', 'regions'): with self.subTest(name=name): self.assertListEqual(getattr(first, name), []) self.assertIsNot(getattr(first, name), getattr(second, name)) def test_positional_arguments(self): with tempfile.TemporaryDirectory() as td: vtt_file = os.path.join(td, 'captions.vtt') content = (PATH_TO_SAMPLES / 'one_caption.vtt').read_text() pathlib.Path(vtt_file).write_text(content) with self.assertRaises(TypeError) as cm: webvtt.WebVTT(vtt_file) self.assertIn('use webvtt.read()', str(cm.exception)) self.assertEqual(pathlib.Path(vtt_file).read_text(), content) def test_deprecated_file_argument(self): with warnings.catch_warnings(record=True) as warns: warnings.simplefilter('always') vtt = webvtt.WebVTT(file='captions.vtt') self.assertEqual(vtt.file, 'captions.vtt') self.assertEqual(len(warns), 1) self.assertIs(warns[0].category, DeprecationWarning) self.assertIn('The file argument of WebVTT() is deprecated', str(warns[0].message)) # the warning points to the code creating the instance self.assertEqual(os.path.basename(warns[0].filename), os.path.basename(__file__)) def test_readers_set_file_without_warnings(self): sample = str(PATH_TO_SAMPLES / 'sample.vtt') with warnings.catch_warnings(): warnings.simplefilter('error') with open(sample, encoding='utf-8') as f: from_open_file = webvtt.from_buffer(f) for vtt, expected in ( (webvtt.read(sample), sample), (webvtt.from_srt(str(PATH_TO_SAMPLES / 'sample.srt')), str(PATH_TO_SAMPLES / 'sample.srt')), (webvtt.from_sbv(str(PATH_TO_SAMPLES / 'sample.sbv')), str(PATH_TO_SAMPLES / 'sample.sbv')), (from_open_file, sample), (webvtt.from_buffer( io.BytesIO(pathlib.Path(sample).read_bytes()) ), None), (webvtt.from_string(pathlib.Path(sample).read_text()), None)): with self.subTest(expected=expected): self.assertEqual(vtt.file, expected) def test_save_to_file_attribute(self): with tempfile.TemporaryDirectory() as td: vtt = webvtt.WebVTT( captions=[Caption('00:00:00.500', '00:00:07.000', 'Caption text #1')] ) vtt.file = os.path.join(td, 'captions.vtt') vtt.save() self.assertEqual( pathlib.Path(td, 'captions.vtt').read_text(), 'WEBVTT\n\n00:00:00.500 --> 00:00:07.000\nCaption text #1\n' ) def test_save_with_path_to_dir_no_filename(self): with tempfile.TemporaryDirectory() as td: self.assertRaises( webvtt.errors.MissingFilenameError, webvtt.WebVTT().save, td ) def test_set_styles_from_text(self): style = Style('::cue(b) {\n color: peachpuff;\n}') self.assertListEqual( style.lines, ['::cue(b) {', ' color: peachpuff;', '}'] ) def test_save_identifiers(self): with named_temporary_file('w', suffix='.vtt') as f: webvtt.read( PATH_TO_SAMPLES / 'using_identifiers.vtt' ).save( f.name ) self.assertListEqual( pathlib.Path(f.name).read_text().splitlines(), [ 'WEBVTT', '', '00:00:00.500 --> 00:00:07.000', 'Caption text #1', '', 'second caption', '00:00:07.000 --> 00:00:11.890', 'Caption text #2', '', '00:00:11.890 --> 00:00:16.320', 'Caption text #3', '', '4', '00:00:16.320 --> 00:00:21.580', 'Caption text #4', '', '00:00:21.580 --> 00:00:23.880', 'Caption text #5', '', '00:00:23.880 --> 00:00:27.280', 'Caption text #6' ] ) def test_save_cue_settings(self): with named_temporary_file('w', suffix='.vtt') as f: vtt = webvtt.read(PATH_TO_SAMPLES / 'netflix_chicas_del_cable.vtt') vtt.save(f.name) lines = pathlib.Path(f.name).read_text( encoding='utf-8' ).splitlines() self.assertIn( '00:00:07.960 --> 00:00:09.480 position:50.00%,middle ' 'align:middle size:80.00% line:84.67%', lines ) def test_save_updated_cue_settings(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'youtube_dl.vtt') vtt.captions[0].settings.line = '0' vtt.captions[1].settings = None lines = vtt.content.splitlines() self.assertIn( '00:04:46.070 --> 00:04:46.470 align:start position:0% line:0', lines ) self.assertIn('00:04:46.470 --> 00:05:04.080', lines) def test_shift_keeps_cue_settings(self): vtt = webvtt.from_string( 'WEBVTT\n\n00:00:01.000 --> 00:00:03.000 line:0 align:start\n' 'Top left\n' ) vtt.shift(2) self.assertEqual( vtt.content, 'WEBVTT\n\n00:00:03.000 --> 00:00:05.000 line:0 align:start\n' 'Top left\n' ) def test_save_as_srt_without_cue_settings(self): vtt = webvtt.from_string( 'WEBVTT\n\n00:00:01.000 --> 00:00:03.000 line:0 align:start\n' 'Top left\n' ) output = io.StringIO() vtt.write(output, format='srt') self.assertEqual( output.getvalue(), '1\n00:00:01,000 --> 00:00:03,000\nTop left\n' ) def test_save_as_srt_keeping_formatting(self): vtt = webvtt.from_string(textwrap.dedent(''' WEBVTT STYLE ::cue(.yellow) { color: yellow; } 00:00:01.000 --> 00:00:03.000 Hello world ''').lstrip() ) output = io.StringIO() vtt.write(output, format='srt', keep_formatting=True) self.assertEqual( output.getvalue(), '1\n00:00:01,000 --> 00:00:03,000\n' 'Hello world\n' ) def test_save_as_srt_keeping_formatting_ignored_for_vtt(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'styles_colours.vtt') output = io.StringIO() vtt.write(output, keep_formatting=True) self.assertEqual( output.getvalue(), (PATH_TO_SAMPLES / 'styles_colours.vtt').read_text( encoding='utf-8' ) ) def test_save_updated_identifiers(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'using_identifiers.vtt') vtt.captions[0].identifier = 'first caption' vtt.captions[1].identifier = None vtt.captions[3].identifier = '44' last_caption = Caption(start='00:00:27.280', end='00:00:29.200', text='Caption text #7' ) last_caption.identifier = 'last caption' vtt.captions.append(last_caption) with named_temporary_file('w', suffix='.vtt') as f: vtt.save(f.name) self.assertListEqual( pathlib.Path(f.name).read_text().splitlines(), [ 'WEBVTT', '', 'first caption', '00:00:00.500 --> 00:00:07.000', 'Caption text #1', '', '00:00:07.000 --> 00:00:11.890', 'Caption text #2', '', '00:00:11.890 --> 00:00:16.320', 'Caption text #3', '', '44', '00:00:16.320 --> 00:00:21.580', 'Caption text #4', '', '00:00:21.580 --> 00:00:23.880', 'Caption text #5', '', '00:00:23.880 --> 00:00:27.280', 'Caption text #6', '', 'last caption', '00:00:27.280 --> 00:00:29.200', 'Caption text #7' ] ) def test_content_formatting(self): """ Verify that content property returns the correctly formatted webvtt. """ captions = [ Caption(start='00:00:00.500', end='00:00:07.000', text=['Caption test line 1', 'Caption test line 2'] ), Caption(start='00:00:08.000', end='00:00:15.000', text=['Caption test line 3', 'Caption test line 4'] ), ] self.assertEqual( webvtt.WebVTT(captions=captions).content, textwrap.dedent(""" WEBVTT 00:00:00.500 --> 00:00:07.000 Caption test line 1 Caption test line 2 00:00:08.000 --> 00:00:15.000 Caption test line 3 Caption test line 4 """).strip() + '\n' ) def test_repr(self): test_file = PATH_TO_SAMPLES / 'sample.vtt' self.assertEqual( repr(webvtt.read(test_file)), f"" ) def test_str(self): self.assertEqual( str(webvtt.read(PATH_TO_SAMPLES / 'sample.vtt')), textwrap.dedent(""" 00:00:00.500 00:00:07.000 Caption text #1 00:00:07.000 00:00:11.890 Caption text #2 00:00:11.890 00:00:16.320 Caption text #3 00:00:16.320 00:00:21.580 Caption text #4 00:00:21.580 00:00:23.880 Caption text #5 00:00:23.880 00:00:27.280 Caption text #6 00:00:27.280 00:00:30.280 Caption text #7 00:00:30.280 00:00:36.510 Caption text #8 00:00:36.510 00:00:38.870 Caption text #9 00:00:38.870 00:00:45.000 Caption text #10 00:00:45.000 00:00:47.000 Caption text #11 00:00:47.000 00:00:50.970 Caption text #12 00:00:50.970 00:00:54.440 Caption text #13 00:00:54.440 00:00:58.600 Caption text #14 00:00:58.600 00:01:01.350 Caption text #15 00:01:01.350 00:01:04.300 Caption text #16 """).strip() ) def test_parse_invalid_file(self): self.assertRaises( MalformedFileError, webvtt.read, PATH_TO_SAMPLES / 'invalid.vtt' ) def test_file_not_found(self): self.assertRaises( FileNotFoundError, webvtt.read, 'nowhere' ) def test_total_length(self): self.assertEqual( webvtt.read(PATH_TO_SAMPLES / 'sample.vtt').total_length, 64 ) def test_total_length_no_captions(self): self.assertEqual( webvtt.WebVTT().total_length, 0 ) def test_shift(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'sample.vtt') durations = [ c.end_time.in_milliseconds() - c.start_time.in_milliseconds() for c in vtt ] vtt.shift(1.25) self.assertEqual(vtt[0].start, '00:00:01.750') self.assertEqual(vtt[0].end, '00:00:08.250') self.assertEqual(vtt[-1].start, '00:01:02.600') self.assertEqual(vtt[-1].end, '00:01:05.550') self.assertEqual( [c.end_time.in_milliseconds() - c.start_time.in_milliseconds() for c in vtt], durations ) def test_shift_negative_clamps_to_zero(self): vtt = webvtt.from_string(textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:01.000 Credits 00:00:01.500 --> 00:00:04.000 Caption #1 00:00:04.000 --> 00:00:07.000 Caption #2 ''').strip()) vtt.shift(-2) self.assertEqual( [(c.start, c.end) for c in vtt], [('00:00:00.000', '00:00:00.000'), ('00:00:00.000', '00:00:02.000'), ('00:00:02.000', '00:00:05.000')] ) self.assertEqual(len(vtt), 3) self.assertEqual(vtt[0].text, 'Credits') def test_shift_timedelta(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'sample.vtt') vtt.shift(datetime.timedelta(minutes=-1, seconds=30)) self.assertEqual(vtt[0].start, '00:00:00.000') self.assertEqual(vtt[0].end, '00:00:00.000') self.assertEqual(vtt[-1].start, '00:00:31.350') self.assertEqual(vtt[-1].end, '00:00:34.300') def test_shift_from_start(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'sample.vtt') before = [(c.start, c.end) for c in vtt] vtt.shift(3, start='00:00:47.000') after = [(c.start, c.end) for c in vtt] self.assertEqual(after[:11], before[:11]) self.assertEqual(after[11], ('00:00:50.000', '00:00:53.970')) self.assertEqual(after[-1], ('00:01:04.350', '00:01:07.300')) def test_shift_from_start_invalid(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'sample.vtt') before = [(c.start, c.end) for c in vtt] with self.assertRaises(MalformedCaptionError): vtt.shift(3, start='47 seconds') self.assertEqual([(c.start, c.end) for c in vtt], before) def test_shift_invalid_offset(self): for value in ('1.5', None, True): with self.subTest(value=value): with self.assertRaises(TypeError): webvtt.WebVTT().shift(value) with self.assertRaises(ValueError): webvtt.WebVTT().shift(float('nan')) def test_shift_invalid_offset_unchanged(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'sample.vtt') before = [(c.start, c.end) for c in vtt] with self.assertRaises(TypeError): vtt.shift('1.5') self.assertEqual([(c.start, c.end) for c in vtt], before) SYNC_CAPTIONS = textwrap.dedent(''' WEBVTT 00:00:01.000 --> 00:00:02.000 Caption #1 00:00:10.000 --> 00:00:12.500 Caption #2 00:01:40.000 --> 00:01:41.000 Caption #3 ''').strip() def timings(self, vtt): return [(c.start, c.end) for c in vtt] def test_sync(self): vtt = webvtt.from_string(self.SYNC_CAPTIONS) vtt.sync(('00:00:01.000', '00:00:02.000'), ('00:01:40.000', '00:02:00.000')) self.assertEqual( self.timings(vtt), [('00:00:02.000', '00:00:03.192'), ('00:00:12.727', '00:00:15.707'), ('00:02:00.000', '00:02:01.192')] ) self.assertEqual([c.text for c in vtt], ['Caption #1', 'Caption #2', 'Caption #3']) def test_sync_point_types_and_order(self): vtt = webvtt.from_string(self.SYNC_CAPTIONS) vtt.sync((datetime.timedelta(seconds=100), 120.0), (Timestamp(seconds=1), 2)) self.assertEqual( self.timings(vtt), [('00:00:02.000', '00:00:03.192'), ('00:00:12.727', '00:00:15.707'), ('00:02:00.000', '00:02:01.192')] ) def test_sync_identity(self): vtt = webvtt.from_string(self.SYNC_CAPTIONS) before = self.timings(vtt) vtt.sync(('00:00:05.000', '00:00:05.000'), [50, 50]) self.assertEqual(self.timings(vtt), before) def test_sync_multiple_points(self): vtt = webvtt.from_string(self.SYNC_CAPTIONS) vtt.sync((0, 0), (10, 10), (100, 190)) self.assertEqual( self.timings(vtt), [('00:00:01.000', '00:00:02.000'), ('00:00:10.000', '00:00:15.000'), ('00:03:10.000', '00:03:12.000')] ) def test_sync_extrapolates_and_clamps_to_zero(self): vtt = webvtt.from_string(self.SYNC_CAPTIONS) vtt.sync((10, 5), (20, 15)) self.assertEqual( self.timings(vtt), [('00:00:00.000', '00:00:00.000'), ('00:00:05.000', '00:00:07.500'), ('00:01:35.000', '00:01:36.000')] ) self.assertEqual(len(vtt), 3) def test_sync_rounds_halves_away_from_zero(self): vtt = webvtt.from_string(textwrap.dedent(''' WEBVTT 00:00:00.001 --> 00:00:00.003 Caption ''').strip()) vtt.sync((0, 0), (2, 3)) self.assertEqual(self.timings(vtt), [('00:00:00.002', '00:00:00.005')]) def test_sync_rounds_times_before_the_first_point(self): vtt = webvtt.from_string(textwrap.dedent(''' WEBVTT 00:00:00.009 --> 00:00:00.011 Caption ''').strip()) # 9 ms is mapped to 8.5 ms and 11 ms to 11.5 ms vtt.sync((0.010, 0.010), (0.012, 0.013)) self.assertEqual(self.timings(vtt), [('00:00:00.009', '00:00:00.012')]) def test_sync_invalid_points(self): for points, error in ( ((), ValueError), (((1, 2),), ValueError), (((1, 2), (1, 3)), ValueError), (((1, 5), (2, 4)), ValueError), (((1, 5), (2, 5)), ValueError), (((1, 2), (3, -4)), ValueError), (((1, 2), (3, float('nan'))), ValueError), (((1, 2), (3,)), TypeError), (((1, 2), '34'), TypeError), (((1, 2), (3, None)), TypeError), (((1, 2), (3, True)), TypeError), (((1, 2), ('00:03', 4)), MalformedCaptionError)): with self.subTest(points=points): vtt = webvtt.from_string(self.SYNC_CAPTIONS) before = self.timings(vtt) with self.assertRaises(error): vtt.sync(*points) self.assertEqual(self.timings(vtt), before) def test_sync_no_captions(self): vtt = webvtt.WebVTT() vtt.sync((1, 2), (3, 4)) self.assertEqual(vtt.captions, []) with self.assertRaises(ValueError): vtt.sync((1, 2)) def test_change_framerate(self): vtt = webvtt.from_string(self.SYNC_CAPTIONS) vtt.change_framerate(25, 23.976) self.assertEqual( self.timings(vtt), [('00:00:01.043', '00:00:02.085'), ('00:00:10.427', '00:00:13.034'), ('00:01:44.271', '00:01:45.314')] ) vtt = webvtt.from_string(self.SYNC_CAPTIONS) vtt.change_framerate(23.976, 25) self.assertEqual( self.timings(vtt), [('00:00:00.959', '00:00:01.918'), ('00:00:09.590', '00:00:11.988'), ('00:01:35.904', '00:01:36.863')] ) def test_change_framerate_exact(self): captions = textwrap.dedent(''' WEBVTT 01:30:00.000 --> 01:30:01.000 Caption ''').strip() vtt = webvtt.from_string(captions) vtt.change_framerate(25, fractions.Fraction(24000, 1001)) self.assertEqual(self.timings(vtt), [('01:33:50.625', '01:33:51.668')]) vtt = webvtt.from_string(captions) vtt.change_framerate(25, 23.976) self.assertEqual(self.timings(vtt), [('01:33:50.631', '01:33:51.673')]) def test_change_framerate_same(self): vtt = webvtt.from_string(self.SYNC_CAPTIONS) before = self.timings(vtt) vtt.change_framerate(29.97, 29.97) self.assertEqual(self.timings(vtt), before) def test_change_framerate_larger_than_a_float(self): for fps in (10 ** 400, fractions.Fraction(10 ** 400)): with self.subTest(fps=type(fps).__name__): vtt = webvtt.from_string(self.SYNC_CAPTIONS) before = self.timings(vtt) vtt.change_framerate(fps, fps) self.assertEqual(self.timings(vtt), before) def test_retime_after_largest_timestamp(self): # only the end of the last caption is moved after the largest # timestamp, 9999999999:59:59.999 offset = datetime.timedelta(milliseconds=35999999999899000) for name, retime in ( ('shift', lambda vtt: vtt.shift(offset)), ('sync', lambda vtt: vtt.sync((0, 0), (1, 10 ** 12))), ('change_framerate', lambda vtt: vtt.change_framerate(10 ** 12, 1))): with self.subTest(method=name): vtt = webvtt.from_string(self.SYNC_CAPTIONS) before = self.timings(vtt) with self.assertRaises(ValueError): retime(vtt) self.assertEqual(self.timings(vtt), before) def test_sync_point_after_largest_timestamp(self): vtt = webvtt.from_string(self.SYNC_CAPTIONS) with self.assertRaises(ValueError): vtt.sync((0, 0), (1, 36000000000000)) def test_change_framerate_invalid(self): for fps, error in ((0, ValueError), (-25, ValueError), (fractions.Fraction(0), ValueError), (fractions.Fraction(-24000, 1001), ValueError), (float('nan'), ValueError), (float('inf'), ValueError), ('25', TypeError), (None, TypeError), (True, TypeError)): with self.subTest(fps=fps): vtt = webvtt.from_string(self.SYNC_CAPTIONS) before = self.timings(vtt) with self.assertRaises(error): vtt.change_framerate(25, fps) with self.assertRaises(error): vtt.change_framerate(fps, 25) self.assertEqual(self.timings(vtt), before) def test_parse_empty_file(self): self.assertRaises( MalformedFileError, webvtt.read, PATH_TO_SAMPLES / 'empty.vtt' ) def test_parse_invalid_timeframe_line(self): good_captions = len( webvtt.read(PATH_TO_SAMPLES / 'invalid_timeframe.vtt').captions ) self.assertEqual(good_captions, 6) def test_parse_cue_timings_in_cue_text_start_new_cue(self): vtt = webvtt.read( PATH_TO_SAMPLES / 'invalid_timeframe_in_cue_text.vtt' ) self.assertEqual(4, len(vtt.captions)) self.assertEqual('', vtt.captions[1].text) self.assertEqual('00:00:07.000', vtt.captions[2].start) self.assertEqual('Caption text #2', vtt.captions[2].text) self.assertEqual('Caption text #3', vtt.captions[3].text) def test_parse_invalid_timeframe_line_strict(self): self.assertRaises( MalformedCaptionError, webvtt.read, PATH_TO_SAMPLES / 'invalid_timeframe.vtt', strict=True ) def test_parse_invalid_timeframe_in_cue_text_strict(self): self.assertRaises( MalformedCaptionError, webvtt.read, PATH_TO_SAMPLES / 'invalid_timeframe_in_cue_text.vtt', strict=True ) def test_parse_valid_file_strict(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'sample.vtt', strict=True) self.assertEqual(len(vtt.captions), 16) def test_parse_non_padded_timestamps(self): """https://github.com/glut23/webvtt-py/issues/44""" vtt = webvtt.read(PATH_TO_SAMPLES / 'non_padded_timestamps.vtt') self.assertEqual(len(vtt.captions), 3) self.assertEqual( [(caption.start, caption.end) for caption in vtt.captions], [('00:00:00.000', '00:00:03.540'), ('00:00:03.540', '00:00:07.230'), ('00:01:05.200', '01:02:07.950')] ) self.assertEqual(vtt.captions[1].voice, 'John Smith') self.assertEqual(vtt.captions[1].text, "Morning, let's get started.") self.assertEqual(vtt.captions[2].end_in_seconds, 3727) self.assertIn( '00:01:05.200 --> 01:02:07.950\nThanks for joining.', vtt.content ) def test_parse_non_padded_timestamps_strict(self): self.assertRaises( MalformedCaptionError, webvtt.read, PATH_TO_SAMPLES / 'non_padded_timestamps.vtt', strict=True ) def test_parse_get_caption_data(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'one_caption.vtt') self.assertEqual(vtt.captions[0].start_in_seconds, 0) self.assertEqual(vtt.captions[0].start, '00:00:00.500') self.assertEqual(vtt.captions[0].end_in_seconds, 7) self.assertEqual(vtt.captions[0].end, '00:00:07.000') self.assertEqual(vtt.captions[0].lines[0], 'Caption text #1') self.assertEqual(len(vtt.captions[0].lines), 1) def test_caption_without_timeframe(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'missing_timeframe.vtt') self.assertEqual(len(vtt.captions), 6) def test_caption_without_timeframe_strict(self): with self.assertRaises(MalformedCaptionError) as cm: webvtt.read(PATH_TO_SAMPLES / 'missing_timeframe.vtt', strict=True) self.assertIn('Caption text #2', str(cm.exception)) def test_caption_without_cue_text(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'missing_caption_text.vtt') self.assertEqual(len(vtt.captions), 5) self.assertEqual(vtt.captions[1].start, '00:00:07.000') self.assertEqual(vtt.captions[1].text, '') def test_timestamps_format(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'sample.vtt') self.assertEqual(vtt.captions[2].start, '00:00:11.890') self.assertEqual(vtt.captions[2].end, '00:00:16.320') def test_parse_timestamp(self): self.assertEqual( Caption(start='02:03:11.890').start_in_seconds, 7391 ) def test_captions_attribute(self): self.assertListEqual(webvtt.WebVTT().captions, []) def test_metadata_headers(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'metadata_headers.vtt') self.assertEqual(len(vtt.captions), 2) def test_metadata_headers_multiline(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'metadata_headers_multiline.vtt') self.assertEqual(len(vtt.captions), 2) self.assertListEqual( vtt.metadata, ['Kind: captions', 'Language: en', 'Style:', '::cue(c.colorCCCCCC) { color: rgb(204, 204, 204);', '}', '##' ] ) def test_metadata_headers_preserved_on_save(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'metadata_headers.vtt') self.assertEqual(vtt.header_text, '') self.assertListEqual(vtt.metadata, ['Kind: captions', 'Language: en']) vtt.shift(1) with tempfile.TemporaryDirectory() as td: output = os.path.join(td, 'captions.vtt') vtt.save(output) with open(output, encoding='utf-8') as f: self.assertEqual( f.read(), textwrap.dedent(''' WEBVTT Kind: captions Language: en 00:00:01.500 --> 00:00:08.000 Caption text #1 00:00:08.000 --> 00:00:12.890 Caption text #2 ''').lstrip() ) def test_header_round_trip(self): content = textwrap.dedent(''' WEBVTT - Sample title X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:126000 00:00:00.000 --> 00:00:02.000 Hello there ''').lstrip() vtt = webvtt.from_string(content) self.assertEqual(vtt.header_text, '- Sample title') self.assertListEqual( vtt.metadata, ['X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:126000'] ) self.assertEqual(vtt.content, content) vtt = webvtt.from_buffer(io.StringIO(content)) self.assertEqual(vtt.header_text, '- Sample title') out = io.StringIO() vtt.write(out) self.assertEqual(out.getvalue(), content) def test_header_in_new_file(self): vtt = webvtt.WebVTT( captions=[ Caption(start='00:00:00.500', end='00:00:07.000', text='Caption #1' ) ], header_text='Sample title' ) vtt.metadata.append('Language: en') self.assertEqual( vtt.content, textwrap.dedent(''' WEBVTT Sample title Language: en 00:00:00.500 --> 00:00:07.000 Caption #1 ''').lstrip() ) def test_save_header_with_other_unicode_line_separators(self): content = ( 'WEBVTT title\u2028more\n' 'Kind: a\x0cb\n' '\n' '00:00:00.500 --> 00:00:07.000\n' 'Caption #1\n' ) with tempfile.TemporaryDirectory() as td: source = os.path.join(td, 'source.vtt') with open(source, 'w', encoding='utf-8', newline='') as f: f.write(content) vtt = webvtt.read(source) self.assertEqual(vtt.header_text, 'title\u2028more') self.assertListEqual(vtt.metadata, ['Kind: a\x0cb']) output = os.path.join(td, 'output.vtt') vtt.save(output) with open(output, encoding='utf-8', newline='') as f: # lines end with the line separator of the platform self.assertEqual(f.read(), content.replace('\n', os.linesep)) def test_save_error_keeps_file(self): with tempfile.TemporaryDirectory() as td: vtt_file = os.path.join(td, 'captions.vtt') srt_file = os.path.join(td, 'captions.srt') vtt_content = textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:07.000 Café ''').lstrip() pathlib.Path(vtt_file).write_text(vtt_content, encoding='utf-8') pathlib.Path(srt_file).write_text('old', encoding='utf-8') vtt = webvtt.read(vtt_file) for save in (lambda: vtt.save(encoding='ascii'), lambda: vtt.save_as_srt(srt_file, encoding='ascii')): with self.assertRaises(UnicodeEncodeError): save() self.assertEqual( pathlib.Path(vtt_file).read_text(encoding='utf-8'), vtt_content ) self.assertEqual(pathlib.Path(srt_file).read_text(), 'old') self.assertEqual(sorted(os.listdir(td)), ['captions.srt', 'captions.vtt']) self.assertEqual(vtt.file, vtt_file) def test_save_text_not_read_back_keeps_file(self): with tempfile.TemporaryDirectory() as td: vtt_file = os.path.join(td, 'captions.vtt') pathlib.Path(vtt_file).write_text( (PATH_TO_SAMPLES / 'one_caption.vtt').read_text() ) vtt = webvtt.read(vtt_file) vtt.captions[0].text = 'line 1\n\nline 2' with self.assertRaises(ValueError): vtt.save() self.assertEqual( pathlib.Path(vtt_file).read_text(), (PATH_TO_SAMPLES / 'one_caption.vtt').read_text() ) self.assertEqual(vtt.file, vtt_file) def test_save_invalid_metadata(self): vtt = webvtt.WebVTT(metadata=['Kind: captions', '']) with tempfile.TemporaryDirectory() as td: output = os.path.join(td, 'captions.vtt') with self.assertRaises(ValueError): vtt.save(output) self.assertFalse(os.path.exists(output)) self.assertIsNone(vtt.file) def test_header_not_in_srt(self): vtt = webvtt.from_string( textwrap.dedent(''' WEBVTT - Sample title Language: en 00:00:00.500 --> 00:00:07.000 Caption #1 ''').lstrip() ) out = io.StringIO() vtt.write(out, format='srt') self.assertEqual( out.getvalue(), '1\n00:00:00,500 --> 00:00:07,000\nCaption #1\n' ) def test_parse_identifiers(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'using_identifiers.vtt') self.assertEqual(len(vtt.captions), 6) self.assertEqual(vtt.captions[1].identifier, 'second caption') self.assertEqual(vtt.captions[2].identifier, None) self.assertEqual(vtt.captions[3].identifier, '4') def test_parse_comments(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'comments.vtt') self.assertEqual(len(vtt.captions), 3) self.assertListEqual( vtt.captions[0].lines, ['- Ta en kopp varmt te.', '- Det är inte varmt.'] ) self.assertListEqual( vtt.captions[0].comments, [] ) self.assertListEqual( vtt.captions[1].comments, [] ) self.assertEqual( vtt.captions[2].text, '- Ta en kopp' ) self.assertListEqual( vtt.captions[2].comments, ['This last line may not translate well.'] ) self.assertListEqual( vtt.header_comments, ['This translation was done by Kyle so that\n' 'some friends can watch it with their parents.' ] ) self.assertListEqual( vtt.footer_comments, ['end of file'] ) def test_parse_styles(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'styles.vtt') self.assertEqual(len(vtt.captions), 1) self.assertEqual( vtt.styles[0].text, '::cue {\n background-image: linear-gradient(to bottom, ' 'dimgray, lightgray);\n color: papayawhip;\n}' ) def test_parse_regions(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'regions.vtt') self.assertEqual(len(vtt.regions), 2) self.assertEqual(vtt.regions[0].id, 'fred') self.assertEqual(vtt.regions[1].regionanchor, '100%,100%') self.assertEqual(len(vtt.captions), 2) self.assertEqual(vtt.captions[1].settings.region, 'bill') def test_parse_regions_strict(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'regions.vtt', strict=True) self.assertEqual(len(vtt.regions), 2) def test_save_regions(self): with named_temporary_file('w', suffix='.vtt') as f: webvtt.read(PATH_TO_SAMPLES / 'regions.vtt').save(f.name) self.assertEqual( pathlib.Path(f.name).read_text(encoding='utf-8'), (PATH_TO_SAMPLES / 'regions.vtt').read_text(encoding='utf-8') ) def test_regions_from_buffer(self): with open(PATH_TO_SAMPLES / 'regions.vtt', encoding='utf-8') as f: vtt = webvtt.from_buffer(f) self.assertListEqual( [region.id for region in vtt.regions], ['fred', 'bill'] ) def test_regions_from_string(self): vtt = webvtt.from_string( 'WEBVTT\n\nREGION\nid:fred\n\n' '00:00:00.500 --> 00:00:04.300 region:fred\nCaption text\n' ) self.assertEqual(vtt.regions[0].id, 'fred') def test_without_regions(self): self.assertListEqual(webvtt.WebVTT().regions, []) vtt = webvtt.read(PATH_TO_SAMPLES / 'sample.vtt') self.assertListEqual(vtt.regions, []) def test_create_regions(self): vtt = webvtt.WebVTT( captions=[ Caption('00:00:00.500', '00:00:04.300', 'Caption text', settings='region:fred') ], regions=[Region(id='fred', width='40%')] ) vtt.regions.append(Region(id='bill')) self.assertEqual( vtt.content, 'WEBVTT\n\nREGION\nid:fred\nwidth:40%\n\nREGION\nid:bill\n\n' '00:00:00.500 --> 00:00:04.300 region:fred\nCaption text\n' ) def test_write_regions(self): output = io.StringIO() webvtt.read(PATH_TO_SAMPLES / 'regions.vtt').write(output) self.assertIn('REGION\nid:bill\n', output.getvalue()) def test_save_as_srt_without_regions(self): output = io.StringIO() webvtt.read(PATH_TO_SAMPLES / 'regions.vtt').write(output, 'srt') self.assertNotIn('REGION', output.getvalue()) self.assertNotIn('region', output.getvalue()) def test_parse_styles_with_comments(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'styles_with_comments.vtt') self.assertEqual(len(vtt.captions), 1) self.assertEqual(len(vtt.styles), 2) self.assertEqual( vtt.styles[0].comments, [] ) self.assertEqual( vtt.styles[0].text, '::cue {\n' ' background-image: linear-gradient(to bottom, dimgray, ' 'lightgray);\n' ' color: papayawhip;\n' '}' ) self.assertEqual( vtt.styles[1].comments, ['This is the second block of styles', 'Multiline comment for the same\nsecond block of styles' ] ) self.assertEqual( vtt.styles[1].text, '::cue(b) {\n' ' color: peachpuff;\n' '}' ) self.assertListEqual( vtt.header_comments, ['Sample of comments with styles'] ) self.assertListEqual( vtt.footer_comments, [] ) def test_multiple_comments_everywhere(self): vtt = webvtt.WebVTT.from_string(textwrap.dedent(""" WEBVTT NOTE Test file NOTE this file is testing multiple comments in different places STYLE ::cue { background-image: linear-gradient(to bottom, dimgray, lightgray); color: papayawhip; } NOTE this style uses nice color NOTE check how it looks before proceeding STYLE ::cue(b) { color: peachpuff; } 00:00:00.500 --> 00:00:07.000 Caption text #1 NOTE next caption has two lines NOTE needs review 00:00:07.000 --> 00:00:11.890 Caption text #2 line 1 Caption text #2 line 2 00:00:11.890 --> 00:00:16.320 Caption text #3 00:00:16.320 --> 00:00:21.580 Caption text #4 NOTE Copyright 2024 NOTE this is the end of the file """).strip() ) self.assertListEqual( vtt.header_comments, ['Test file', 'this file is testing multiple comments\nin different places' ] ) self.assertListEqual( vtt.footer_comments, ['Copyright 2024', 'this is the end of the file' ] ) self.assertListEqual( vtt.captions[0].comments, [] ) self.assertListEqual( vtt.captions[1].comments, ['next caption has two lines', 'needs review' ] ) self.assertListEqual( vtt.captions[2].comments, [] ) self.assertListEqual( vtt.captions[3].comments, [] ) self.assertListEqual( vtt.styles[0].comments, [] ) self.assertListEqual( vtt.styles[1].comments, ['this style uses nice color', 'check how it looks before proceeding' ] ) def test_comments_in_new_file(self): with named_temporary_file('r', suffix='.vtt', encoding='utf-8') as f: vtt = webvtt.WebVTT() vtt.header_comments.append('This is a header comment') vtt.header_comments.append( 'where we can see a\ntwo line comment' ) vtt.styles.append( Style('::cue(b) {\n color: peachpuff;\n}') ) style = Style('::cue {\n color: papayawhip;\n}') style.comments.append('Another style to test\nthe look and feel') style.comments.append('Please check') vtt.styles.append(style) vtt.captions.append( Caption(start='00:00:00.500', end='00:00:07.000', text='Caption #1', ) ) caption = Caption(start='00:00:07.000', end='00:00:11.890', text='Caption #2' ) caption.comments.append( 'Second caption may be a bit off\nand needs checking' ) caption.comments.append('Confirm if it displays correctly') vtt.captions.append(caption) vtt.footer_comments.append('This is a footer comment') vtt.footer_comments.append( 'where we can also see a\ntwo line comment' ) vtt.save(f.name) self.assertEqual( f.read(), textwrap.dedent(''' WEBVTT NOTE This is a header comment NOTE where we can see a two line comment STYLE ::cue(b) { color: peachpuff; } NOTE Another style to test the look and feel NOTE Please check STYLE ::cue { color: papayawhip; } 00:00:00.500 --> 00:00:07.000 Caption #1 NOTE Second caption may be a bit off and needs checking NOTE Confirm if it displays correctly 00:00:07.000 --> 00:00:11.890 Caption #2 NOTE This is a footer comment NOTE where we can also see a two line comment ''').strip() ) def test_clean_cue_tags(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'cue_tags.vtt') self.assertEqual( vtt.captions[1].text, 'Like a big-a pizza pie' ) self.assertEqual( vtt.captions[2].text, 'That\'s amore' ) def test_parse_captions_with_bom(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'captions_with_bom.vtt') self.assertEqual(len(vtt.captions), 4) def test_empty_lines_are_not_included_in_result(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'netflix_chicas_del_cable.vtt') self.assertEqual(vtt.captions[0].text, "[Alba] En 1928,") self.assertEqual( vtt.captions[-2].text, "Diez años no son suficientes\npara olvidarte..." ) def test_can_parse_youtube_dl_files(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'youtube_dl.vtt') self.assertEqual(len(vtt.captions), 4) self.assertEqual('00:04:46.470', vtt.captions[1].start) self.assertEqual('yeah\nwhat', vtt.captions[1].text) self.assertEqual( "this will happen is I'm telling", vtt.captions[2].text ) self.assertEqual( "this will happen is I'm telling\n ", vtt.captions[3].text ) def test_parse_voice_spans(self): vtt = webvtt.from_string(textwrap.dedent(""" WEBVTT 00:00:00.000 --> 00:00:00.800 Knock knock 00:00:02.100 --> 00:00:06.500 Who's there? 00:00:10.530 --> 00:00:11.090 Atish """).strip() ) self.assertEqual(len(vtt), 3) self.assertEqual( str(vtt[0]), '00:00:00.000 00:00:00.800 Knock knock' ) self.assertEqual( vtt[0].voice, 'Lisa Simpson' ) self.assertEqual( str(vtt[1]), '00:00:02.100 00:00:06.500 Who\'s there?' ) self.assertEqual( vtt[1].voice, 'Homer Simpson' ) self.assertEqual( str(vtt[2]), '00:00:10.530 00:00:11.090 Atish' ) self.assertEqual( vtt[2].voice, 'Lisa Simpson' ) def test_parse_caption_not_a_voice_span(self): vtt = webvtt.from_string(textwrap.dedent(""" WEBVTT 00:00:00.000 --> 00:00:00.800 00:00:07.000 Caption text #1 ''').strip() + '\n' ) def test_save_file_with_bom_keeps_bom(self): with named_temporary_file('r', suffix='.vtt', encoding='utf-8') as f: webvtt.read( PATH_TO_SAMPLES / 'captions_with_bom.vtt' ).save(f.name) self.assertEqual( f.read(), textwrap.dedent(f''' {CODEC_BOMS['utf-8'].decode()}WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:11.890 Caption text #2 00:00:11.890 --> 00:00:16.320 Caption text #3 00:00:16.320 --> 00:00:21.580 Caption text #4 ''').strip() + '\n' ) def test_save_file_with_bom_removes_bom_if_requested(self): with named_temporary_file('r', suffix='.vtt', encoding='utf-8') as f: webvtt.read( PATH_TO_SAMPLES / 'captions_with_bom.vtt' ).save(f.name, add_bom=False) self.assertEqual( f.read(), textwrap.dedent(f''' WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 00:00:07.000 --> 00:00:11.890 Caption text #2 00:00:11.890 --> 00:00:16.320 Caption text #3 00:00:16.320 --> 00:00:21.580 Caption text #4 ''').strip() + '\n' ) def test_save_file_with_encoding(self): with named_temporary_file('rb', suffix='.vtt') as f: webvtt.read( PATH_TO_SAMPLES / 'one_caption.vtt' ).save(f.name, encoding='utf-32-le' ) # lines end with the line separator of the platform self.assertEqual( f.read().decode('utf-32-le'), (textwrap.dedent(''' WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 ''').strip() + '\n').replace('\n', os.linesep) ) def test_save_file_with_encoding_and_bom(self): with named_temporary_file('rb', suffix='.vtt') as f: webvtt.read( PATH_TO_SAMPLES / 'one_caption.vtt' ).save(f.name, encoding='utf-32-le', add_bom=True ) # lines end with the line separator of the platform self.assertEqual( f.read().decode('utf-32-le'), (textwrap.dedent(f''' {CODEC_BOMS['utf-32-le'].decode('utf-32-le')}WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 ''').strip() + '\n').replace('\n', os.linesep) ) def test_save_new_file_utf_8_default_encoding_no_bom(self): with named_temporary_file('r', suffix='.vtt', encoding='utf-8') as f: vtt = webvtt.WebVTT() vtt.captions.append( Caption(start='00:00:00.500', end='00:00:07.000', text='Caption text #1' ) ) vtt.save(f.name) self.assertEqual(vtt.encoding, 'utf-8') self.assertEqual( f.read(), textwrap.dedent(f''' WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 ''').strip() + '\n' ) def test_save_new_file_utf_8_default_encoding_with_bom(self): with named_temporary_file('r', suffix='.vtt', encoding='utf-8') as f: vtt = webvtt.WebVTT() vtt.captions.append( Caption(start='00:00:00.500', end='00:00:07.000', text='Caption text #1' ) ) vtt.save(f.name, add_bom=True ) self.assertEqual(vtt.encoding, 'utf-8') self.assertEqual( f.read(), textwrap.dedent(f''' {CODEC_BOMS['utf-8'].decode()}WEBVTT 00:00:00.500 --> 00:00:07.000 Caption text #1 ''').strip() + '\n' ) def test_save_encoding_names(self): with_bom = webvtt.read(PATH_TO_SAMPLES / 'captions_with_bom.vtt') without_bom = webvtt.read(PATH_TO_SAMPLES / 'one_caption.vtt') for vtt, encoding, add_bom, expected in ( (with_bom, 'UTF-8', None, 'utf-8'), (with_bom, 'utf8', None, 'utf-8'), (without_bom, 'UTF-16LE', True, 'utf-16-le'), (without_bom, 'utf_32_be', True, 'utf-32-be')): with self.subTest(encoding=encoding), \ tempfile.TemporaryDirectory() as td: path = os.path.join(td, 'captions.vtt') vtt.save(path, encoding=encoding, add_bom=add_bom) data = pathlib.Path(path).read_bytes() self.assertTrue(data.startswith(CODEC_BOMS[expected])) self.assertTrue( data[len(CODEC_BOMS[expected]):].decode(expected) .startswith('WEBVTT') ) def test_save_codecs_writing_bom(self): with_bom = webvtt.read(PATH_TO_SAMPLES / 'captions_with_bom.vtt') without_bom = webvtt.read(PATH_TO_SAMPLES / 'one_caption.vtt') for encoding in ('utf-8-sig', 'UTF-16', 'utf_32'): for vtt, add_bom in ((without_bom, None), (without_bom, True), (with_bom, None)): with self.subTest(encoding=encoding, add_bom=add_bom, file=vtt.file), \ tempfile.TemporaryDirectory() as td: path = os.path.join(td, 'captions.vtt') vtt.save(path, encoding=encoding, add_bom=add_bom) # a single Byte Order Mark, written by the codec self.assertTrue( pathlib.Path(path).read_bytes() .decode(encoding).startswith('WEBVTT') ) def test_save_codecs_writing_bom_without_bom(self): vtt = webvtt.read(PATH_TO_SAMPLES / 'one_caption.vtt') for encoding in ('utf-8-sig', 'utf-16', 'utf-32'): with self.subTest(encoding=encoding), \ tempfile.TemporaryDirectory() as td: path = pathlib.Path(td, 'captions.vtt') path.write_text('original') with self.assertRaises(ValueError) as cm: vtt.save(path, encoding=encoding, add_bom=False) self.assertIn('always writes a Byte Order Mark', str(cm.exception)) self.assertEqual(path.read_text(), 'original') def test_save_encoding_without_bom(self): with tempfile.TemporaryDirectory() as td: path = pathlib.Path(td, 'captions.vtt') path.write_text('original') vtt = webvtt.read(PATH_TO_SAMPLES / 'captions_with_bom.vtt') with self.assertRaises(ValueError) as cm: vtt.save(path, encoding='latin-1', add_bom=True) self.assertIn('The latin-1 encoding has no Byte Order Mark', str(cm.exception)) self.assertEqual(path.read_text(), 'original') # the Byte Order Mark of the file that was read is left out vtt.save(path, encoding='latin-1') self.assertTrue(path.read_bytes().startswith(b'WEBVTT')) def test_save_unknown_encoding(self): with tempfile.TemporaryDirectory() as td: path = pathlib.Path(td, 'captions.vtt') path.write_text('original') with self.assertRaises(LookupError): webvtt.read(PATH_TO_SAMPLES / 'one_caption.vtt').save( path, encoding='unknown' ) self.assertEqual(path.read_text(), 'original') def test_iter_slice(self): vtt = webvtt.read( PATH_TO_SAMPLES / 'sample.vtt' ) slice_of_captions = vtt.iter_slice(start='00:00:11.000', end='00:00:27.000' ) for expected_caption in (vtt.captions[2], vtt.captions[3], vtt.captions[4] ): self.assertIs(expected_caption, next(slice_of_captions)) with self.assertRaises(StopIteration): next(slice_of_captions) def test_iter_slice_no_start_time(self): vtt = webvtt.read( PATH_TO_SAMPLES / 'sample.vtt' ) slice_of_captions = vtt.iter_slice(end='00:00:27.000') for expected_caption in (vtt.captions[0], vtt.captions[1], vtt.captions[2], vtt.captions[3], vtt.captions[4] ): self.assertIs(expected_caption, next(slice_of_captions)) with self.assertRaises(StopIteration): next(slice_of_captions) def test_iter_slice_no_end_time(self): vtt = webvtt.read( PATH_TO_SAMPLES / 'sample.vtt' ) slice_of_captions = vtt.iter_slice(start='00:00:47.000') for expected_caption in (vtt.captions[11], vtt.captions[12], vtt.captions[13], vtt.captions[14], vtt.captions[15] ): self.assertIs(expected_caption, next(slice_of_captions)) with self.assertRaises(StopIteration): next(slice_of_captions) glut23-webvtt-py-cca74e4/webvtt/000077500000000000000000000000001527171777000166165ustar00rootroot00000000000000glut23-webvtt-py-cca74e4/webvtt/__init__.py000066400000000000000000000007731527171777000207360ustar00rootroot00000000000000"""Main webvtt package.""" __version__ = '0.7.0' __author__ = 'Alejandro Mendez' __author_email__ = 'amendez23@gmail.com' from .webvtt import WebVTT from . import segmenter from .models import Caption, CueSettings, Region, Style # noqa __all__ = ['WebVTT', 'Caption', 'CueSettings', 'Region', 'Style'] read = WebVTT.read read_buffer = WebVTT.read_buffer from_buffer = WebVTT.from_buffer from_srt = WebVTT.from_srt from_sbv = WebVTT.from_sbv from_string = WebVTT.from_string segment = segmenter.segment glut23-webvtt-py-cca74e4/webvtt/__main__.py000066400000000000000000000002241527171777000207060ustar00rootroot00000000000000"""Run the command line interface with ``python -m webvtt``.""" import sys from .cli import main if __name__ == '__main__': sys.exit(main()) glut23-webvtt-py-cca74e4/webvtt/cli.py000066400000000000000000000315141527171777000177430ustar00rootroot00000000000000"""CLI module.""" import argparse import errno import fractions import math import os import pathlib import sys import typing from . import __version__, segmenter from .webvtt import WebVTT from .models import Timestamp, _sync_mapping from .errors import ( MalformedFileError, MalformedCaptionError, MissingFilenameError ) # the path that reads the captions from standard input _STDIN = '-' def main(argv: typing.Optional[typing.Sequence] = None): """ Segment, shift or sync a WebVTT file from command line. :param argv: command line arguments """ arguments = argparse.ArgumentParser( prog='webvtt', description='Segment, shift and sync WebVTT caption files.' ) arguments.add_argument( '--version', action='version', version=f'%(prog)s {__version__}' ) commands = arguments.add_subparsers( dest='command', required=True, help='command to perform' ) segment_command = commands.add_parser( 'segment', help='segment a WebVTT file for HLS', description='Segment WebVTT files for HTTP Live Streaming (HLS).', epilog=( 'The output directory will contain a playlist named ' 'prog_index.m3u8 and one fileSequenceN.webvtt file per segment. ' 'Captions spanning multiple segments are included in each of ' 'them. An output directory with the files of an earlier run is ' 'an error unless --overwrite is given.' ) ) segment_command.add_argument( 'file', metavar='PATH', help='WebVTT file, or - to read standard input' ) segment_command.add_argument( '-o', '--output', metavar='PATH', required=True, help='output directory, created if it does not exist' ) segment_command.add_argument( '-d', '--target-duration', metavar='NUMBER', type=_duration, default=segmenter.DEFAULT_SECONDS, help='target duration of each segment in seconds, default: %(default)s' ) segment_command.add_argument( '-m', '--mpegts', metavar='NUMBER', type=_mpegts, default=segmenter.DEFAULT_MPEGTS, help=('MPEG-TS value for the X-TIMESTAMP-MAP header of each segment, ' 'default: %(default)s') ) segment_command.add_argument( '--duration', metavar='TIME', type=_media_duration, help=('length of the media, as a timestamp or a number of seconds, ' 'so the playlist covers all of it and its last segment ends ' 'with it') ) segment_command.add_argument( '--overwrite', action='store_true', help=('replace the playlist and segments of an earlier run in the ' 'output directory') ) segment_command.set_defaults(run=_segment) shift_command = commands.add_parser( 'shift', help='shift the timestamps of a WebVTT file', description='Shift the timestamps of the captions in a WebVTT file.', epilog=( 'The offset is rounded to the nearest millisecond, halves away ' 'from zero, and a timestamp that would fall before zero is set ' 'to 00:00:00.000. ' 'The captions are written to standard output unless an output ' 'file is given.' ) ) shift_command.add_argument( 'file', metavar='PATH', help='WebVTT file, or - to read standard input' ) shift_command.add_argument( 'offset', metavar='OFFSET', type=_offset, help=('seconds to shift the captions by, negative values move them ' 'earlier, e.g. 2 or -1.5') ) shift_command.add_argument( '-o', '--output', metavar='PATH', help='output file, can be the same as the input file' ) shift_command.add_argument( '-s', '--start', metavar='TIMESTAMP', type=_timestamp, help='only shift the captions starting at or after this timestamp' ) shift_command.set_defaults(run=_shift) sync_command = commands.add_parser( 'sync', help='synchronize a WebVTT file that drifts out of sync', description=( 'Synchronize the captions in a WebVTT file to two or more points ' 'or to a different frame rate.' ), epilog=( 'Times between two points are moved along the line between them, ' 'and times before the first or after the last point along the ' 'line of the outermost points. Times are rounded to the nearest ' 'millisecond, halves away from zero, and a time that would fall ' 'before zero is set to 00:00:00.000. The captions are written to ' 'standard output unless an output file is given.' ) ) sync_command.add_argument( 'file', metavar='PATH', help='WebVTT file, or - to read standard input' ) timing = sync_command.add_mutually_exclusive_group(required=True) timing.add_argument( '-p', '--point', metavar=('FROM', 'TO'), nargs=2, type=_position, action='append', dest='points', help=('move the time FROM in the captions to TO, as a timestamp or ' 'seconds; repeat it for two or more points') ) timing.add_argument( '--fps', metavar=('FROM', 'TO'), nargs=2, type=_fps, help=('change the timing from the frame rate FROM to TO, ' 'e.g. 25 23.976 or 25 24000/1001') ) sync_command.add_argument( '-o', '--output', metavar='PATH', help='output file, can be the same as the input file' ) sync_command.set_defaults(run=_sync) args = arguments.parse_args(argv) _check_arguments(arguments, sync_command, args) name = 'standard input' if args.file == _STDIN else args.file try: args.run(args) except OSError as error: message = f'{error.filename or name}: {error.strerror or error}' except UnicodeDecodeError as error: message = f'{name}: cannot be decoded as {error.encoding}' except MalformedFileError: message = f'{name}: not a valid WebVTT file' except ValueError as error: # the captions cannot be moved as asked, e.g. after the largest # timestamp message = f'{name}: {error}' except MissingFilenameError: # the output is a directory, and standard input has no file name # to save to in it message = (f'{args.output}: is a directory, give a file name to ' 'save the captions read from standard input') else: return arguments.exit(1, f'{arguments.prog}: error: {message}\n') def _check_arguments( parser: argparse.ArgumentParser, sync_parser: argparse.ArgumentParser, args: argparse.Namespace ): """Exit with an error if the arguments cannot be used.""" if args.command == 'sync': _check_sync_points(sync_parser, args.points) elif args.command == 'segment' and not args.overwrite: _check_output(parser, args.output) def _check_sync_points( parser: argparse.ArgumentParser, points: typing.Optional[typing.List[typing.List[typing.Any]]] ): """Exit with a usage error if the sync points cannot be used.""" try: if points: _sync_mapping(points) except ValueError as error: parser.error(str(error)) def _check_output(parser: argparse.ArgumentParser, output: str): """Exit with an error if the output has the files of an earlier run.""" try: earlier_file = segmenter._earlier_output(pathlib.Path(output)) except OSError: # reported as the other errors when segmenting return if earlier_file: parser.exit( 1, f'{parser.prog}: error: {output}: has the files of an earlier ' f'segmentation, such as {earlier_file}; use --overwrite to ' 'replace them\n' ) def _segment(args: argparse.Namespace): """Run the segment command.""" segmenter.segment( _read(args.file), args.output, args.target_duration, args.mpegts, overwrite=args.overwrite, duration=args.duration ) def _shift(args: argparse.Namespace): """Run the shift command.""" vtt = _read(args.file) vtt.shift(args.offset, start=args.start) _write(vtt, args.output) def _sync(args: argparse.Namespace): """Run the sync command.""" vtt = _read(args.file) if args.points: vtt.sync(*args.points) else: vtt.change_framerate(*args.fps) _write(vtt, args.output) def _read(path: str) -> WebVTT: """Read the captions of a file, or of standard input for ``-``.""" if path != _STDIN: return WebVTT.read(path) if sys.stdin is None: # standard input is closed, as in ``webvtt shift - 1 <&-`` raise OSError(errno.EBADF, os.strerror(errno.EBADF)) # read the bytes, decoded as UTF-8 as WebVTT requires, whatever the # encoding of standard input is return WebVTT.from_buffer(getattr(sys.stdin, 'buffer', sys.stdin)) def _write(vtt: WebVTT, output: typing.Optional[str]): """Save the captions to the output file or standard output.""" if output: vtt.save(output) else: _write_stdout(vtt.content) def _write_stdout(content: str): """ Write WebVTT content to standard output. The content is encoded in UTF-8 as WebVTT requires, whatever the encoding of standard output is. """ try: if hasattr(sys.stdout, 'buffer'): sys.stdout.flush() sys.stdout.buffer.write(content.encode('utf-8')) else: sys.stdout.write(content) sys.stdout.flush() except BrokenPipeError: # the reader stopped early, as in ``webvtt shift ... | head``; # point standard output to devnull so Python does not fail again # flushing it at exit os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) sys.exit(1) def _offset(value: str) -> float: """ Return the offset in seconds of a command line argument. ``WebVTT.shift`` rounds the offset to the nearest millisecond as written, e.g. ``1.0005`` is 1001 milliseconds. """ offset = _number(value) if not math.isfinite(offset): raise argparse.ArgumentTypeError(f'invalid offset: {value!r}') return offset def _position(value: str) -> typing.Union[str, float]: """Return a timestamp or a number of seconds of a command line argument.""" try: return _timestamp(value) except argparse.ArgumentTypeError: seconds = _number(value) if not math.isfinite(seconds) or seconds < 0: raise argparse.ArgumentTypeError(f'invalid timestamp: {value!r}') return seconds def _fps(value: str) -> fractions.Fraction: """ Return the frame rate of a command line argument. The frame rate is read exactly as written, as a number such as ``23.976`` or a ratio such as ``24000/1001``. """ try: fps = fractions.Fraction(value) except (ValueError, ZeroDivisionError): fps = fractions.Fraction(0) if fps <= 0: raise argparse.ArgumentTypeError(f'invalid frame rate: {value!r}') return fps def _duration(value: str) -> int: """Return the segment duration of a command line argument.""" try: seconds = int(value) segmenter._check_seconds(seconds) except ValueError: raise argparse.ArgumentTypeError(f'invalid duration: {value!r}') return seconds def _media_duration(value: str) -> typing.Union[str, float]: """Return the length of the media of a command line argument.""" try: duration = _position(value) segmenter._duration_in_milliseconds(duration) except (argparse.ArgumentTypeError, ValueError): raise argparse.ArgumentTypeError(f'invalid duration: {value!r}') return duration def _mpegts(value: str) -> int: """Return the MPEG-TS value of a command line argument.""" try: mpegts = int(value) segmenter._check_mpegts(mpegts) except ValueError: raise argparse.ArgumentTypeError(f'invalid MPEG-TS value: {value!r}') return mpegts def _number(value: str) -> float: """Return a command line argument as a float, NaN if not a number.""" try: return float(value) except ValueError: return math.nan def _timestamp(value: str) -> str: """Return a command line argument after validating the timestamp.""" try: Timestamp.from_string(value) except MalformedCaptionError: raise argparse.ArgumentTypeError(f'invalid timestamp: {value!r}') return value if __name__ == '__main__': main() # pragma: no cover glut23-webvtt-py-cca74e4/webvtt/errors.py000066400000000000000000000030651527171777000205100ustar00rootroot00000000000000"""Errors module.""" class WebVTTError(Exception): """ Base class of the errors of webvtt-py. Catching it catches every error defined in this module. """ class MalformedFileError(WebVTTError, ValueError): """ File is not in the right format. Raised when reading captions whose content is not valid for the expected format (WebVTT, SRT or SBV), e.g. a WebVTT file missing the ``WEBVTT`` header. """ class MalformedCaptionError(WebVTTError, ValueError): """ Caption not in the right format. Raised when a timestamp is not valid, e.g. when creating a :class:`~webvtt.models.Caption`, setting its ``start`` or ``end`` or reading a file that contains a cue with an invalid timestamp. Also raised when a cue setting is not valid, e.g. when setting one of :class:`~webvtt.models.CueSettings`. """ class MalformedRegionError(MalformedCaptionError): """ Region not in the right format. Raised when a region setting is not valid, e.g. when creating a :class:`~webvtt.models.Region` or setting one of its settings, or when reading a file with strict parsing that contains a region with a setting that is not valid or a region after the first cue. It is a subclass of :class:`MalformedCaptionError`, so catching that error also catches it. """ class MissingFilenameError(WebVTTError): """ Missing a filename when saving to disk. Raised when saving captions without an output path and the :class:`~webvtt.webvtt.WebVTT` instance was not read from a file. """ glut23-webvtt-py-cca74e4/webvtt/models.py000066400000000000000000001174451527171777000204670ustar00rootroot00000000000000"""Models module.""" import bisect import collections.abc import datetime import decimal import fractions import functools import math import re import typing from .errors import MalformedCaptionError, MalformedRegionError from .utils import _split_lines # a context of our own, so rounding does not depend on the decimal context # of the caller _DECIMAL_CONTEXT = decimal.Context() # the largest timestamp, 9999999999:59:59.999, as hours have up to 10 digits, # far more than any video lasts, so times past it are rejected the same on # every Python version instead of converting huge numbers, which is slow # before Python 3.11 and raises ValueError after it _MAX_MILLISECONDS = 10 ** 10 * 3_600_000 - 1 _OUT_OF_RANGE = 'Time after the largest timestamp 9999999999:59:59.999' @functools.total_ordering class Timestamp: """Representation of a timestamp.""" # hours have up to 10 digits, see _MAX_MILLISECONDS PATTERN = re.compile( r'(?:([0-9]{1,10}):)?([0-9]{1,2}):([0-9]{1,2})\.([0-9]{1,3})' ) def __init__( self, hours: int = 0, minutes: int = 0, seconds: int = 0, milliseconds: int = 0 ): """Initialize.""" self.hours = hours self.minutes = minutes self.seconds = seconds self.milliseconds = milliseconds def __str__(self): """Return the string representation of the timestamp.""" return ( f'{self.hours:02d}:{self.minutes:02d}:{self.seconds:02d}' f'.{self.milliseconds:03d}' ) def to_tuple(self) -> typing.Tuple[int, int, int, int]: """Return the timestamp in tuple form.""" return self.hours, self.minutes, self.seconds, self.milliseconds def __repr__(self): """Return the string representation of the caption.""" return (f'<{self.__class__.__name__} ' f'hours={self.hours} ' f'minutes={self.minutes} ' f'seconds={self.seconds} ' f'milliseconds={self.milliseconds}>' ) def __eq__(self, other): """Compare equality with other object.""" if not isinstance(other, Timestamp): return NotImplemented return self.to_tuple() == other.to_tuple() def __lt__(self, other: 'Timestamp') -> bool: """Compare less than with other object.""" if not isinstance(other, Timestamp): return NotImplemented return self.to_tuple() < other.to_tuple() @classmethod def from_string(cls, value: str) -> 'Timestamp': """ Return a `Timestamp` instance from a string value. The fields can be shorter than the WebVTT format requires, as in ``0:1:5.2``, and the fraction of a second is padded on the right so ``.2`` is read as 200 milliseconds. The hours can have up to 10 digits. :param value: the timestamp in ``[hh:]mm:ss.mmm`` format :returns: a `Timestamp` instance :raises MalformedCaptionError: if the timestamp is not valid """ if type(value) is not str: raise MalformedCaptionError(f'Invalid timestamp {value!r}') match = cls.PATTERN.fullmatch(value) if not match: raise MalformedCaptionError(f'Invalid timestamp {value!r}') hours = int(match.group(1) or 0) minutes = int(match.group(2)) seconds = int(match.group(3)) milliseconds = int(match.group(4).ljust(3, '0')) if minutes > 59 or seconds > 59: raise MalformedCaptionError(f'Invalid timestamp {value!r}') return cls(hours, minutes, seconds, milliseconds) @classmethod def from_seconds(cls, value: float) -> 'Timestamp': """ Return a `Timestamp` instance from a number of seconds. The value is rounded to the nearest millisecond as written, with halves rounded up, so ``1.9996`` and ``1.9995`` are read as ``00:00:02.000``. :param value: the timestamp in seconds :returns: a `Timestamp` instance :raises MalformedCaptionError: if the value is not a finite number of seconds greater than or equal to zero, or is after the largest timestamp """ if ( type(value) is bool or not isinstance(value, (int, float)) or not _is_finite(value) or value < 0 ): raise MalformedCaptionError(f'Invalid timestamp {value!r}') try: return cls._from_milliseconds(_in_milliseconds(value)) except ValueError as error: raise MalformedCaptionError(str(error)) from None @classmethod def _from_milliseconds(cls, value: int) -> 'Timestamp': """ Return a `Timestamp` instance from a number of milliseconds. :param value: the timestamp in milliseconds, zero or greater :returns: a `Timestamp` instance :raises ValueError: if the value is after the largest timestamp """ if value > _MAX_MILLISECONDS: raise ValueError(_OUT_OF_RANGE) hours, remainder = divmod(value, 3_600_000) minutes, remainder = divmod(remainder, 60_000) seconds, milliseconds = divmod(remainder, 1000) return cls(hours, minutes, seconds, milliseconds) def in_seconds(self) -> int: """ Return the timestamp in seconds. The milliseconds are left out, use `in_milliseconds` for the exact value. """ return (self.hours * 3600 + self.minutes * 60 + self.seconds ) def in_milliseconds(self) -> int: """Return the timestamp in milliseconds.""" return self.in_seconds() * 1000 + self.milliseconds def _is_finite(value: typing.Union[float, fractions.Fraction]) -> bool: """ Return whether a number is finite. Integers and fractions are always finite, and are not passed to ``math.isfinite``, which raises OverflowError for those too large to convert to a float. """ return not isinstance(value, float) or math.isfinite(value) def _in_milliseconds(value: typing.Union[float, datetime.timedelta]) -> int: """ Return a number of seconds or a ``timedelta`` in milliseconds. The value is rounded to the nearest millisecond with halves rounded away from zero. A float is rounded as written, from its shortest representation, so ``259.0395`` is 259040 milliseconds although the closest float is slightly smaller. A number past the range of a timestamp, in either direction, is returned as one millisecond past it, as converting a huge number can overflow. :param value: a finite number of seconds or a ``timedelta`` :returns: the value in milliseconds """ if isinstance(value, datetime.timedelta): seconds = decimal.Decimal( value // datetime.timedelta(microseconds=1) ).scaleb(-6, context=_DECIMAL_CONTEXT) elif abs(value) >= (_MAX_MILLISECONDS + 1) // 1000: return _MAX_MILLISECONDS + 1 if value > 0 else -_MAX_MILLISECONDS - 1 elif isinstance(value, float): seconds = decimal.Decimal(float.__repr__(value)) else: seconds = decimal.Decimal(value) return int( seconds.scaleb(3, context=_DECIMAL_CONTEXT).to_integral_value( rounding=decimal.ROUND_HALF_UP, context=_DECIMAL_CONTEXT ) ) def _offset_in_milliseconds( offset: typing.Union[float, datetime.timedelta], name: str = 'offset' ) -> int: """ Return a shift offset in milliseconds. :param offset: a number of seconds or a ``timedelta`` :param name: what the value is, for the error messages :returns: the offset rounded to the nearest millisecond, halves away from zero :raises TypeError: if the offset is not a number or a ``timedelta`` :raises ValueError: if the offset is not finite """ if isinstance(offset, datetime.timedelta): return _in_milliseconds(offset) if type(offset) is bool or not isinstance(offset, (int, float)): raise TypeError(f'Invalid {name} {offset!r}') if not _is_finite(offset): raise ValueError(f'Invalid {name} {offset!r}') return _in_milliseconds(offset) _Position = typing.Union[str, Timestamp, float, datetime.timedelta] def _position_in_milliseconds(value: _Position) -> int: """ Return a position in the captions in milliseconds. :param value: a timestamp, as a string or a `Timestamp`, a number of seconds or a ``timedelta`` :returns: the position rounded to the nearest millisecond, halves away from zero :raises MalformedCaptionError: if the timestamp string is not valid :raises TypeError: if the value is not of a supported type :raises ValueError: if the value is not finite, is before zero or is after the largest timestamp """ if isinstance(value, str): return Timestamp.from_string(value).in_milliseconds() if isinstance(value, Timestamp): return value.in_milliseconds() milliseconds = _offset_in_milliseconds(value, name='timestamp') if milliseconds < 0: raise ValueError(f'Invalid timestamp {value!r}') if milliseconds > _MAX_MILLISECONDS: raise ValueError(_OUT_OF_RANGE) return milliseconds def _divide_rounded(numerator: int, denominator: int) -> int: """ Divide two integers rounding to the nearest integer, halves away from zero. :param numerator: the dividend :param denominator: the divisor, greater than zero :returns: the rounded quotient """ quotient, remainder = divmod(abs(numerator), denominator) quotient += 2 * remainder >= denominator return quotient if numerator >= 0 else -quotient def _sync_mapping( points: typing.Sequence[typing.Sequence[_Position]] ) -> typing.Callable[[int], int]: """ Return the function mapping times in milliseconds for sync points. Times are mapped along the line between the two neighbouring points, and before the first or after the last point along the line of the outermost points. :param points: two or more ``(from, to)`` pairs :returns: the mapping of milliseconds to milliseconds :raises MalformedCaptionError: if a timestamp string is not valid :raises TypeError: if a point is not a pair of supported values :raises ValueError: if there are fewer than two points, two points have the same ``from`` time, or the points would change the order of the captions """ if len(points) < 2: raise ValueError('At least two sync points are required') pairs = sorted(_sync_point(point) for point in points) for (from_1, to_1), (from_2, to_2) in zip(pairs, pairs[1:]): if from_1 == from_2: raise ValueError( 'Two sync points have the same time ' f'{Timestamp._from_milliseconds(from_1)}' ) if to_1 >= to_2: raise ValueError( 'Sync points must keep the order of the captions, ' f'{Timestamp._from_milliseconds(from_1)} is moved to ' f'{Timestamp._from_milliseconds(to_1)} but ' f'{Timestamp._from_milliseconds(from_2)} to ' f'{Timestamp._from_milliseconds(to_2)}' ) from_times = [from_time for from_time, _ in pairs] def mapping(milliseconds: int) -> int: index = bisect.bisect_right(from_times, milliseconds) - 1 index = min(max(index, 0), len(pairs) - 2) (from_1, to_1), (from_2, to_2) = pairs[index], pairs[index + 1] return _divide_rounded( to_1 * (from_2 - from_1) + (milliseconds - from_1) * (to_2 - to_1), from_2 - from_1 ) return mapping def _sync_point(point: typing.Sequence[_Position]) -> typing.Tuple[int, int]: """ Return a ``(from, to)`` sync point in milliseconds. :param point: a pair of positions :returns: the pair in milliseconds :raises TypeError: if the point is not a pair """ if not isinstance(point, (tuple, list)) or len(point) != 2: raise TypeError(f'Invalid sync point {point!r}') return (_position_in_milliseconds(point[0]), _position_in_milliseconds(point[1])) _FrameRate = typing.Union[float, fractions.Fraction] def _framerate_mapping( from_fps: _FrameRate, to_fps: _FrameRate ) -> typing.Callable[[int], int]: """ Return the function mapping times in milliseconds between frame rates. :param from_fps: the frame rate the captions are timed for :param to_fps: the frame rate of the video :returns: the mapping of milliseconds to milliseconds :raises TypeError: if a frame rate is not a number :raises ValueError: if a frame rate is not finite or not above zero """ ratio = _frame_rate(from_fps) / _frame_rate(to_fps) return lambda milliseconds: _divide_rounded( milliseconds * ratio.numerator, ratio.denominator ) def _frame_rate(value: _FrameRate) -> fractions.Fraction: """ Return a frame rate as an exact fraction, as written. :param value: the frame rate, e.g. ``25``, ``23.976`` or ``Fraction(24000, 1001)`` :returns: the frame rate as a fraction :raises TypeError: if the frame rate is not a number :raises ValueError: if the frame rate is not finite or not above zero """ if ( type(value) is bool or not isinstance(value, (int, float, fractions.Fraction)) ): raise TypeError(f'Invalid frame rate {value!r}') if not _is_finite(value) or value <= 0: raise ValueError(f'Invalid frame rate {value!r}') if isinstance(value, float): return fractions.Fraction(decimal.Decimal(float.__repr__(value))) return fractions.Fraction(value) def _iter_cue_text_tokens( line: str ) -> typing.Generator[typing.Tuple[str, bool], None, None]: """ Iterate the text and the cue tags of a single line of text. A ``<`` without a ``>`` after it is part of the text. The scan stops at the first one, as no later ``<`` on the line has a ``>`` after it either, which keeps it linear in the length of the line. :param line: a line of cue text :returns: tuples of the text or the tag content and true for a tag """ pos = 0 while True: start = line.find('<', pos) if start < 0: break end = line.find('>', start + 1) if end < 0: break if start > pos: yield line[pos:start], False yield line[start + 1:end], True pos = end + 1 if pos < len(line): yield line[pos:], False def _strip_cue_text_tags(line: str) -> str: """ Remove cue tags from a single line of text. Gives the same result as ``re.sub('<.*?>', '', line)`` for a line without a newline, in linear time. That regex rescans the rest of the line from every ``<`` that has no ``>`` after it, which is quadratic in the length of the line. """ return ''.join( token for token, is_tag in _iter_cue_text_tokens(line) if not is_tag ) # the characters WebVTT treats as spaces when splitting settings _SETTINGS_SEPARATOR = re.compile('[ \t\n\f\r]+') _PERCENTAGE = re.compile(r'[0-9]+(?:\.[0-9]+)?%') _LINE_NUMBER = re.compile(r'-?[0-9]+') _NUMBER_OF_LINES = re.compile(r'[0-9]+') def _is_percentage(value: str) -> bool: """Return whether the value is a WebVTT percentage from 0 to 100.""" return bool(_PERCENTAGE.fullmatch(value)) and float(value[:-1]) <= 100 def _is_valid_line(value: str) -> bool: """Return whether the value is valid for the line cue setting.""" line, separator, alignment = value.partition(',') return ( (_is_percentage(line) or bool(_LINE_NUMBER.fullmatch(line))) and (not separator or alignment in ('start', 'center', 'end')) ) def _is_valid_position(value: str) -> bool: """Return whether the value is valid for the position cue setting.""" position, separator, alignment = value.partition(',') return ( _is_percentage(position) and (not separator or alignment in ('line-left', 'center', 'line-right')) ) def _is_valid_anchor(value: str) -> bool: """Return whether the value is two percentages separated by a comma.""" x, separator, y = value.partition(',') return bool(separator) and _is_percentage(x) and _is_percentage(y) def _is_valid_identifier(value: str) -> bool: """Return whether the value is a valid region identifier.""" return ( bool(value) and '-->' not in value and not _SETTINGS_SEPARATOR.search(value) ) def _split_setting(setting: str) -> typing.Optional[typing.Tuple[str, str]]: """ Split a setting in name and value at its first colon. :param setting: a setting as written, e.g. ``line:0`` :returns: the name and value, or None when there is no colon or it is the first or last character """ name, _, value = setting.partition(':') if not name or not value: return None return name, value def _setting_name(setting: str) -> typing.Optional[str]: """Return the name of a setting, or None when it has no name.""" parts = _split_setting(setting) return parts[0] if parts else None def _setting_property(name: str, doc: str) -> property: """Return a property to get and set the setting with that name.""" return property( lambda self: self._get(name), lambda self, value: self._set(name, value), doc=doc ) _SettingsT = typing.TypeVar('_SettingsT', bound='_Settings') class _Settings: """ Base for settings written as ``name:value`` and separated by spaces. The settings are kept as written and in their original order, so they are written back the same, including those that are not valid or not known. A property returns the value of its setting when it is valid, the last valid one when it is repeated, and validates a new value. """ VALIDATORS: typing.ClassVar[ typing.Dict[str, typing.Callable[[str], bool]] ] = {} # what a setting is called in error messages _KIND = 'setting' # the error raised for settings that are not valid _ERROR: typing.ClassVar[ typing.Type[MalformedCaptionError] ] = MalformedCaptionError def __init__(self): """Initialize without settings.""" self._settings: typing.List[str] = [] @classmethod def from_string( cls: typing.Type[_SettingsT], value: str, strict: bool = False ) -> _SettingsT: """ Return an instance from settings separated by spaces. :param value: the settings, e.g. ``line:0 align:start`` :param strict: raise an error on settings that are not known, not valid or repeated instead of keeping them :returns: an instance with the settings :raises MalformedCaptionError: if the value is not a string, or if strict and a setting is not known, not valid or repeated """ if type(value) is not str: raise cls._ERROR(f'Invalid {cls._KIND}s {value!r}') settings = cls() settings._settings = [ setting for setting in _SETTINGS_SEPARATOR.split(value) if setting ] if strict: names: typing.Set[str] = set() for setting in settings._settings: parts = _split_setting(setting) if (not parts or parts[0] not in cls.VALIDATORS or not cls.VALIDATORS[parts[0]](parts[1])): raise cls._ERROR(f'Invalid {cls._KIND} {setting!r}') if parts[0] in names: raise cls._ERROR(f'Repeated {cls._KIND} {setting!r}') names.add(parts[0]) return settings def __str__(self): """Return the settings separated by a space.""" return ' '.join(self._settings) def __repr__(self): """Return the string representation of the settings.""" return f'<{self.__class__.__name__} text={str(self)!r}>' def __bool__(self): """Return whether there is any setting.""" return bool(self._settings) def __iter__(self) -> typing.Iterator[str]: """Iterate the settings as written, e.g. ``line:0``.""" return iter(self._settings) def __eq__(self, other): """Compare equality with another object.""" if not isinstance(other, type(self)): return False return self._settings == other._settings def _get(self, name: str) -> typing.Optional[str]: """Return the value of the last valid setting with that name.""" value = None for setting in self._settings: parts = _split_setting(setting) if (parts and parts[0] == name and self.VALIDATORS[name](parts[1])): value = parts[1] return value def _set(self, name: str, value: typing.Optional[str]): """ Set the setting with that name, or remove it when the value is None. Every setting with that name is replaced by a single one where the first of them was, or added at the end when there is none. :raises MalformedCaptionError: if the value is not valid """ if value is not None and ( type(value) is not str or not self.VALIDATORS[name](value) ): raise self._ERROR(f'Invalid {name} {self._KIND} {value!r}') indexes = [ index for index, setting in enumerate(self._settings) if _setting_name(setting) == name ] for index in reversed(indexes): del self._settings[index] if value is not None: self._settings.insert( indexes[0] if indexes else len(self._settings), f'{name}:{value}' ) class CueSettings(_Settings): """ Representation of the cue settings of a caption. The settings are kept as written and in their original order, so they are written back the same, including those that are not valid or not known. A property returns the value of its setting when it is valid, the last valid one when it is repeated, and validates a new value. """ VALIDATORS = { 'vertical': lambda value: value in ('rl', 'lr'), 'line': _is_valid_line, 'position': _is_valid_position, 'size': _is_percentage, 'align': lambda value: value in ( 'start', 'center', 'end', 'left', 'right' ), 'region': _is_valid_identifier, } _KIND = 'cue setting' def __init__(self, *, vertical: typing.Optional[str] = None, line: typing.Optional[str] = None, position: typing.Optional[str] = None, size: typing.Optional[str] = None, align: typing.Optional[str] = None, region: typing.Optional[str] = None ): """ Initialize. :param vertical: writing direction, ``rl`` or ``lr`` :param line: line position, as a line number or a percentage with an optional alignment, e.g. ``-1`` or ``10%,end`` :param position: position as a percentage with an optional alignment, e.g. ``50%,line-left`` :param size: size as a percentage, e.g. ``80%`` :param align: text alignment, ``start``, ``center``, ``end``, ``left`` or ``right`` :param region: identifier of a region :raises MalformedCaptionError: if a value is not valid """ super().__init__() self.vertical = vertical self.line = line self.position = position self.size = size self.align = align self.region = region @classmethod def from_string(cls, value: str, strict: bool = False) -> 'CueSettings': """ Return a `CueSettings` instance from the settings of a cue. :param value: the cue settings, e.g. ``line:0 align:start`` :param strict: raise an error on settings that are not known, not valid or repeated instead of keeping them :returns: a `CueSettings` instance :raises MalformedCaptionError: if the value is not a string, or if strict and a setting is not known, not valid or repeated """ return super().from_string(value, strict) vertical = _setting_property( 'vertical', 'Writing direction, ``rl`` or ``lr``.' ) line = _setting_property( 'line', 'Line position, as a line number or a percentage with an optional ' 'alignment.' ) position = _setting_property( 'position', 'Position as a percentage with an optional alignment.' ) size = _setting_property('size', 'Size as a percentage.') align = _setting_property( 'align', 'Text alignment, ``start``, ``center``, ``end``, ``left`` or ' '``right``.' ) region = _setting_property('region', 'Identifier of a region.') def _to_cue_settings( value: typing.Optional[typing.Union[str, CueSettings]] ) -> CueSettings: """ Return a new `CueSettings` instance from the value. :param value: the cue settings as a string, a `CueSettings` instance that is copied, or None for no settings :returns: a `CueSettings` instance :raises MalformedCaptionError: if the value is not a string, a `CueSettings` instance or None """ if isinstance(value, CueSettings): settings = CueSettings() settings._settings = list(value._settings) return settings return CueSettings.from_string('' if value is None else value) class Caption: """Representation of a caption.""" VOICE_SPAN_PATTERN = re.compile(r'\s][^>]*)>') def __init__(self, start: typing.Optional[str] = None, end: typing.Optional[str] = None, text: typing.Optional[typing.Union[str, typing.Sequence[str] ]] = None, identifier: typing.Optional[str] = None, settings: typing.Optional[typing.Union[str, CueSettings ]] = None ): """ Initialize. :param start: start time of the caption :param end: end time of the caption :param text: the text of the caption, or a sequence of its lines :param identifier: optional identifier :param settings: optional cue settings, as a string such as ``line:0 align:start`` or a `CueSettings` instance :raises MalformedCaptionError: if start or end are not valid, or settings is not a string, a `CueSettings` instance or None :raises TypeError: if text is not a string or a sequence of strings """ self.start = start or '00:00:00.000' self.end = end or '00:00:00.000' self.identifier = identifier self._settings = _to_cue_settings(settings) self.lines = _caption_lines([] if text is None else text) self.comments: typing.List[str] = [] def __repr__(self): """Return the string representation of the caption.""" cleaned_text = self.text.replace('\n', '\\n') return (f'<{self.__class__.__name__} ' f'start={self.start!r} ' f'end={self.end!r} ' f'text={cleaned_text!r} ' f'identifier={self.identifier!r} ' f'settings={str(self.settings)!r}>' ) def __str__(self): """Return a readable representation of the caption.""" cleaned_text = self.text.replace('\n', '\\n') return f'{self.start} {self.end} {cleaned_text}' def __eq__(self, other): """Compare equality with another object.""" if not isinstance(other, type(self)): return False return (self.start == other.start and self.end == other.end and self.raw_text == other.raw_text and self.identifier == other.identifier and self.settings == other.settings ) @property def start(self): """Return the start time of the caption.""" return str(self.start_time) @start.setter def start(self, value: str): """Set the start time of the caption.""" self.start_time = Timestamp.from_string(value) @property def end(self): """Return the end time of the caption.""" return str(self.end_time) @end.setter def end(self, value: str): """Set the end time of the caption.""" self.end_time = Timestamp.from_string(value) @property def settings(self) -> CueSettings: """Return the cue settings of the caption.""" return self._settings @settings.setter def settings(self, value: typing.Optional[typing.Union[str, CueSettings]]): """ Set the cue settings of the caption, or clear them when None. A `CueSettings` instance is copied, so it is not shared with the caption it may come from. :raises MalformedCaptionError: if the value is not a string, a `CueSettings` instance or None """ self._settings = _to_cue_settings(value) @property def start_in_seconds(self) -> int: """Return the start time of the caption in seconds.""" return self.start_time.in_seconds() @property def end_in_seconds(self): """Return the end time of the caption in seconds.""" return self.end_time.in_seconds() def shift(self, offset: typing.Union[float, datetime.timedelta]): """ Shift the start and end time of the caption. The offset is rounded to the nearest millisecond, halves away from zero, and a timestamp that would fall before zero is set to ``00:00:00.000``. :param offset: a number of seconds or a ``timedelta``, negative values move the caption earlier :raises TypeError: if the offset is not a number or a ``timedelta`` :raises ValueError: if the offset is not finite, or would move a time after the largest timestamp, leaving the caption unchanged """ offset_ms = _offset_in_milliseconds(offset) _retime_captions([self], lambda milliseconds: milliseconds + offset_ms) def _retimed( self, mapping: typing.Callable[[int], int] ) -> typing.Tuple[Timestamp, Timestamp]: """ Return the start and end time of the caption mapped to new times. A time mapped before zero is set to ``00:00:00.000``. :param mapping: the function mapping a time in milliseconds to the new time in milliseconds :returns: the new start and end time :raises ValueError: if a time is mapped after the largest timestamp """ return ( Timestamp._from_milliseconds( max(mapping(self.start_time.in_milliseconds()), 0) ), Timestamp._from_milliseconds( max(mapping(self.end_time.in_milliseconds()), 0) ) ) @property def raw_text(self) -> str: """Return the text of the caption (including cue tags).""" return '\n'.join(self.lines) @property def text(self) -> str: """Return the text of the caption (without cue tags).""" return '\n'.join( _strip_cue_text_tags(line) for line in self.raw_text.split('\n') ) @text.setter def text(self, value: typing.Union[str, typing.Sequence[str]]): """ Set the text of the caption. :param value: the text, or a sequence of its lines :raises TypeError: if the value is not a string or a sequence of strings """ self.lines = _caption_lines(value) @property def voice(self) -> typing.Optional[str]: """Return the voice span if present.""" if self.lines and self.lines[0].startswith(' typing.List[str]: """ Return the lines of the text of a caption. :param text: the text, split on its line breaks, or its lines :returns: the lines :raises TypeError: if the text is not a string or an iterable of strings """ if isinstance(text, str): return _split_lines(text) # bytes are iterable, as integers lines = (list(text) if isinstance(text, collections.abc.Iterable) and not isinstance(text, (bytes, bytearray)) else None ) if lines is None or not all(isinstance(line, str) for line in lines): raise TypeError( f'Expected the text as a string or a sequence of lines, ' f'got {text!r}' ) return lines def _retime_captions( captions: typing.Iterable[Caption], mapping: typing.Callable[[int], int] ): """ Change the start and end time of the captions with a mapping. The new times of every caption are computed before any caption is changed, so the captions are left unchanged when one cannot be. :param captions: the captions to change :param mapping: the function mapping a time in milliseconds to the new time in milliseconds :raises ValueError: if a time is mapped after the largest timestamp """ captions = list(captions) times = [caption._retimed(mapping) for caption in captions] for caption, (start_time, end_time) in zip(captions, times): caption.start_time, caption.end_time = start_time, end_time def _timing_in_milliseconds(value: str) -> int: """ Return the milliseconds of a SRT or SBV timestamp. As in most SRT readers, the separator before the milliseconds can be a comma or a dot, the milliseconds are read as a number, so ``07,5`` is 5 milliseconds, and fields over their range carry over, so ``07,1500`` is 8.5 seconds. :param value: the timestamp in ``hh:mm:ss,mmm`` format :returns: the timestamp in milliseconds :raises ValueError: if a field is past the range of a timestamp """ fields = re.split('[:,.]', value) # a field with more than 17 significant digits is past the largest # timestamp whatever its unit, so it is rejected without converting it if any(len(field.lstrip('0')) > 17 for field in fields): raise ValueError(_OUT_OF_RANGE) hours, minutes, seconds, milliseconds = map(int, fields) return ((hours * 60 + minutes) * 60 + seconds) * 1000 + milliseconds def _caption_from_timings( start: str, end: str, lines: typing.Sequence[str] ) -> typing.Optional[Caption]: """ Return a caption from the cue timings of SRT or SBV. :param start: the start time in ``hh:mm:ss,mmm`` format :param end: the end time in ``hh:mm:ss,mmm`` format :param lines: the lines of the caption text :returns: a `Caption` instance, or None if a time cannot be read or is after the largest timestamp """ try: start_time, end_time = ( Timestamp._from_milliseconds(_timing_in_milliseconds(timing)) for timing in (start, end) ) except ValueError: return None return Caption(str(start_time), str(end_time), lines) class Style: """Representation of a style.""" def __init__(self, text: typing.Union[str, typing.List[str]]): """Initialize. :param text: the style text, or a list of its lines, which is copied, as for a caption """ self.lines = (_split_lines(text) if isinstance(text, str) else list(text) ) self.comments: typing.List[str] = [] @property def text(self): """Return the text of the style.""" return '\n'.join(self.lines) class Region(_Settings): """ Representation of a region. A region is an area of the video where captions are rendered, defined in a REGION block of the header and referred to by its identifier in the ``region`` cue setting of the captions. The settings are kept as written and in their original order, so they are written back the same, including those that are not valid or not known. A property returns the value of its setting when it is valid, the last valid one when it is repeated, and validates a new value. """ VALIDATORS = { 'id': _is_valid_identifier, 'width': _is_percentage, 'lines': lambda value: bool(_NUMBER_OF_LINES.fullmatch(value)), 'regionanchor': _is_valid_anchor, 'viewportanchor': _is_valid_anchor, 'scroll': lambda value: value == 'up', } _KIND = 'region setting' _ERROR = MalformedRegionError def __init__(self, *, id: typing.Optional[str] = None, width: typing.Optional[str] = None, lines: typing.Optional[str] = None, regionanchor: typing.Optional[str] = None, viewportanchor: typing.Optional[str] = None, scroll: typing.Optional[str] = None ): """ Initialize. :param id: identifier of the region :param width: width as a percentage of the video, e.g. ``40%`` :param lines: number of lines of text, e.g. ``3`` :param regionanchor: point of the region anchored to the video, as two percentages of the region, e.g. ``0%,100%`` :param viewportanchor: point of the video the region is anchored to, as two percentages of the video, e.g. ``10%,90%`` :param scroll: ``up`` for the lines to scroll up :raises MalformedRegionError: if a value is not valid """ super().__init__() self.id = id self.width = width self.lines = lines self.regionanchor = regionanchor self.viewportanchor = viewportanchor self.scroll = scroll self.comments: typing.List[str] = [] @classmethod def from_string(cls, value: str, strict: bool = False) -> 'Region': """ Return a `Region` instance from the settings of a region block. :param value: the region settings separated by spaces or line breaks, e.g. ``id:fred width:40%`` :param strict: raise an error on settings that are not known, not valid or repeated instead of keeping them :returns: a `Region` instance :raises MalformedRegionError: if the value is not a string or contains ``-->``, which cannot be in a region block, or if strict and a setting is not known, not valid or repeated """ if type(value) is str and '-->' in value: raise MalformedRegionError(f'Invalid region settings {value!r}') return super().from_string(value, strict) id = _setting_property('id', 'Identifier of the region.') width = _setting_property( 'width', 'Width as a percentage of the video.' ) lines = _setting_property('lines', 'Number of lines of text.') regionanchor = _setting_property( 'regionanchor', 'Point of the region anchored to the video, as two percentages.' ) viewportanchor = _setting_property( 'viewportanchor', 'Point of the video the region is anchored to, as two percentages.' ) scroll = _setting_property('scroll', '``up`` if the lines scroll up.') glut23-webvtt-py-cca74e4/webvtt/py.typed000066400000000000000000000000001527171777000203030ustar00rootroot00000000000000glut23-webvtt-py-cca74e4/webvtt/sbv.py000066400000000000000000000064641527171777000177740ustar00rootroot00000000000000"""SBV format module.""" import typing import re from . import utils from .models import Caption, _caption_from_timings from .errors import MalformedFileError class SBVCueBlock: """Representation of a cue timing block.""" CUE_TIMINGS_PATTERN = re.compile( r'\s*([0-9]+:[0-9]{1,2}:[0-9]{1,2}\.[0-9]{3}),' r'([0-9]+:[0-9]{1,2}:[0-9]{1,2}\.[0-9]{3})' ) def __init__( self, start: str, end: str, payload: typing.Sequence[str] ): """ Initialize. :param start: start time :param end: end time :param payload: caption text """ self.start = start self.end = end self.payload = payload @classmethod def is_valid( cls, lines: typing.Sequence[str] ) -> bool: """ Validate the lines for a match of a cue time block. :param lines: the lines to be validated :returns: true for a matching cue time block """ return bool( len(lines) >= 2 and re.match(cls.CUE_TIMINGS_PATTERN, lines[0]) and lines[1].strip() ) @classmethod def from_lines( cls, lines: typing.Sequence[str] ) -> 'SBVCueBlock': """ Create a `SBVCueBlock` from lines of text. :param lines: the lines of text :returns: `SBVCueBlock` instance """ match = re.match(cls.CUE_TIMINGS_PATTERN, lines[0]) assert match is not None payload = lines[1:] return cls(match.group(1), match.group(2), payload) def parse(lines: typing.Sequence[str]) -> typing.List[Caption]: """ Parse SBV captions from lines of text. :param lines: lines of text :returns: list of `Caption` objects :raises MalformedFileError: if the content is not in SBV format """ if not _is_valid_content(lines): raise MalformedFileError('Invalid format') return _parse_captions(lines) def _is_valid_content(lines: typing.Sequence[str]) -> bool: """ Validate lines of text for valid SBV content. The content is valid when it starts with the cue timings, after any blank lines. The first cue can have no text. :param lines: lines of text :returns: true for a valid SBV content """ first_block = next(utils.iter_blocks_of_lines(lines), None) return bool( first_block and re.match(SBVCueBlock.CUE_TIMINGS_PATTERN, first_block[0]) ) def _parse_captions(lines: typing.Sequence[str]) -> typing.List[Caption]: """ Parse captions from the text. The cue timings are read as in SRT, so a field over its range carries over, as in ``0:61:00.000``. :param lines: lines of text :returns: list of `Caption` objects """ captions = [] for block_lines in utils.iter_blocks_of_lines(lines): if not SBVCueBlock.is_valid(block_lines): continue cue_block = SBVCueBlock.from_lines(block_lines) caption = _caption_from_timings(cue_block.start, cue_block.end, cue_block.payload ) if caption is not None: captions.append(caption) return captions glut23-webvtt-py-cca74e4/webvtt/segmenter.py000066400000000000000000000300751527171777000211660ustar00rootroot00000000000000"""Segmenter module.""" import typing import os import pathlib import re from . import utils, vtt from .models import ( Caption, Region, Style, Timestamp, _Position, _position_in_milliseconds ) from .webvtt import WebVTT DEFAULT_MPEGTS = 900000 DEFAULT_SECONDS = 10 # default number of seconds per segment # MPEG-TS timestamps have 33 bits _MAX_MPEGTS = 2 ** 33 - 1 _MANIFEST_NAME = 'prog_index.m3u8' _SEGMENT_NAME_PATTERN = re.compile(r'fileSequence(0|[1-9][0-9]*)\.webvtt') # ignoring case, as on case-insensitive file systems these are the names # that would be written over _EARLIER_OUTPUT_PATTERN = re.compile( r'prog_index\.m3u8|fileSequence(0|[1-9][0-9]*)\.webvtt', re.IGNORECASE ) def segment( webvtt_path: typing.Union[str, WebVTT], output: str, seconds: int = DEFAULT_SECONDS, mpegts: int = DEFAULT_MPEGTS, *, overwrite: bool = False, duration: typing.Optional[_Position] = None ): """ Segment a WebVTT captions file, or captions already read. Each segment has the styles and regions of the file and the captions shown during the segment, with their identifiers and cue settings. Comments are not written to the segments. :param webvtt_path: the path to the file, or a `WebVTT` instance, such as captions edited or read from another format, which is not changed :param output: the path to the destination folder :param seconds: the number of seconds for each segment, a whole number above zero :param mpegts: value for the MPEG-TS, from 0 to 2**33 - 1 :param overwrite: replace the playlist and segments of an earlier run in the destination folder, deleting the segments the new playlist does not have. Other files in the folder are left alone. :param duration: the length of the media, as a timestamp, a number of seconds or a ``timedelta``, so the playlist covers all of it and its last segment ends with it. Without it, the playlist ends with the segment of the last caption, and every segment lasts the number of seconds. :raises TypeError: if seconds or mpegts is not an integer, or duration is not of a supported type :raises ValueError: if seconds is not above zero, mpegts is out of range, duration is not above zero or a caption does not start before it :raises FileExistsError: if the destination folder has the playlist or segments of an earlier run and overwrite is false """ _check_seconds(seconds) _check_mpegts(mpegts) if duration is not None: _duration_in_milliseconds(duration) captions_file = (webvtt_path if isinstance(webvtt_path, WebVTT) else WebVTT.read(webvtt_path)) segments = slice_segments(captions_file.captions, seconds, duration=duration) output_folder = pathlib.Path(output) if not overwrite: earlier_file = _earlier_output(output_folder) if earlier_file: raise FileExistsError( f'{str(output_folder)!r} has the files of an earlier ' f'segmentation, such as {earlier_file!r}; use ' 'overwrite=True to replace them' ) os.makedirs(output_folder, exist_ok=True) write_segments(output_folder, segments, mpegts, styles=captions_file.styles, regions=captions_file.regions ) write_manifest(output_folder, segments, seconds, duration=duration) if overwrite: _remove_segments_from(output_folder, len(segments)) def _earlier_output(output_folder: pathlib.Path) -> typing.Optional[str]: """ Find the playlist or a segment of an earlier run in the folder. :returns: the name of the first one, None if there is none or the folder does not exist """ if not output_folder.is_dir(): return None return next( (name for name in sorted(p.name for p in output_folder.iterdir()) if _EARLIER_OUTPUT_PATTERN.fullmatch(name)), None ) def _remove_segments_from(output_folder: pathlib.Path, index: int): """Delete the segment files numbered from index onwards.""" for path in output_folder.iterdir(): match = _SEGMENT_NAME_PATTERN.fullmatch(path.name) if match and int(match.group(1)) >= index and path.is_file(): path.unlink(missing_ok=True) def _check_seconds(seconds: int): """ Validate the number of seconds of each segment. :raises TypeError: if it is not an integer :raises ValueError: if it is not above zero """ if type(seconds) is bool or not isinstance(seconds, int): raise TypeError(f'Invalid segment duration {seconds!r}') if seconds <= 0: raise ValueError(f'Invalid segment duration {seconds!r}') def _check_mpegts(mpegts: int): """ Validate the MPEG-TS value of the segments. :raises TypeError: if it is not an integer :raises ValueError: if it is not from 0 to 2**33 - 1 """ if type(mpegts) is bool or not isinstance(mpegts, int): raise TypeError(f'Invalid MPEG-TS value {mpegts!r}') if not 0 <= mpegts <= _MAX_MPEGTS: raise ValueError(f'Invalid MPEG-TS value {mpegts!r}') def _duration_in_milliseconds(duration: _Position) -> int: """ Return the length of the media in milliseconds. :param duration: a timestamp, as a string or a `Timestamp`, a number of seconds or a ``timedelta`` :returns: the duration rounded to the nearest millisecond, halves away from zero :raises TypeError: if it is not of a supported type :raises ValueError: if it is not a valid timestamp or not above zero """ try: milliseconds = _position_in_milliseconds(duration) except TypeError: raise TypeError(f'Invalid duration {duration!r}') from None except ValueError: milliseconds = 0 if milliseconds <= 0: raise ValueError(f'Invalid duration {duration!r}') return milliseconds def slice_segments( captions: typing.Sequence[Caption], seconds: int, *, duration: typing.Optional[_Position] = None ) -> typing.List[typing.List[Caption]]: """ Slice segments of captions based on seconds per segment. :param captions: the captions :param seconds: seconds per segment, a whole number above zero :param duration: the length of the media, as a timestamp, a number of seconds or a ``timedelta``, so the segments cover all of it, with a caption ending after it in the last segment. Without it, the segments end with the segment of the last caption. :returns: list of lists of `Caption` objects :raises TypeError: if seconds is not an integer, or duration is not of a supported type :raises ValueError: if seconds is not above zero, duration is not above zero or a caption does not start before it """ _check_seconds(seconds) segment_milliseconds = seconds * 1000 # the first and the last segment of each caption, the end time # exclusive, so a caption ending on a boundary is not in the next one indexes = [ (c.start_time.in_milliseconds() // segment_milliseconds, (c.end_time.in_milliseconds() - 1) // segment_milliseconds) for c in captions ] if duration is None: # one segment at least, so the playlist is not empty total_segments = max( (max(first, last) for first, last in indexes), default=0 ) + 1 else: duration_milliseconds = _duration_in_milliseconds(duration) for c in captions: if c.start_time.in_milliseconds() >= duration_milliseconds: end = Timestamp._from_milliseconds(duration_milliseconds) raise ValueError( f'Caption at {c.start} does not start before the end ' f'of the media at {end}' ) total_segments = -(-duration_milliseconds // segment_milliseconds) # a caption ending after the media is in the last segment indexes = [(first, min(last, total_segments - 1)) for first, last in indexes] segments: typing.List[typing.List[Caption]] = [ [] for _ in range(total_segments) ] for c, (first, last) in zip(captions, indexes): # a caption ending before it starts is in the segment it starts in for i in range(first, max(first, last) + 1): segments[i].append(c) return segments def write_segments( output_folder: pathlib.Path, segments: typing.Iterable[typing.Iterable[Caption]], mpegts: int, styles: typing.Iterable[Style] = (), regions: typing.Iterable[Region] = () ): """ Write the segments to the output folder. Every segment is built before any is written, so a caption or style that cannot be written leaves the files of the folder unchanged. :param output_folder: folder where the segment files will be stored :param segments: the segments of `Caption` objects :param mpegts: value for the MPEG-TS, from 0 to 2**33 - 1 :param styles: the styles written to every segment :param regions: the regions written to every segment, which the ``region`` cue setting of the captions refers to :raises TypeError: if mpegts is not an integer :raises ValueError: if mpegts is out of range, or the text of a caption or style could not be read back """ _check_mpegts(mpegts) styles = list(styles) regions = list(regions) metadata = [f'X-TIMESTAMP-MAP=MPEGTS:{mpegts},LOCAL:00:00:00.000'] contents = [ vtt._to_str(segment, styles, [], [], metadata=metadata, regions=regions, item_comments=False ) for segment in segments ] for index, content in enumerate(contents): segment_file = output_folder / f'fileSequence{index}.webvtt' utils._write_text_file(str(segment_file), content, 'utf-8') def write_manifest( output_folder: pathlib.Path, segments: typing.Iterable[typing.Iterable[Caption]], seconds: int, *, duration: typing.Optional[_Position] = None ): """ Write the manifest in the output folder. :param output_folder: folder where the manifest will be stored :param segments: the segments of `Caption` objects :param seconds: the seconds per segment, a whole number above zero :param duration: the length of the media, as a timestamp, a number of seconds or a ``timedelta``, for the duration of the last segment, which ends with it. Without it, every segment lasts the number of seconds. :raises TypeError: if seconds is not an integer, or duration is not of a supported type :raises ValueError: if seconds is not above zero, or duration is not above zero or does not end in the last segment """ _check_seconds(seconds) segment_milliseconds = seconds * 1000 durations = [segment_milliseconds for _ in segments] if duration is not None: duration_milliseconds = _duration_in_milliseconds(duration) last_start = (len(durations) - 1) * segment_milliseconds if not (last_start < duration_milliseconds <= last_start + segment_milliseconds): end = Timestamp._from_milliseconds(duration_milliseconds) raise ValueError( f'Duration {end} does not end in the last of ' f'{len(durations)} segments of {seconds} seconds' ) durations[-1] = duration_milliseconds - last_start manifest_file = output_folder / _MANIFEST_NAME with open(manifest_file, 'w', encoding='utf-8') as f: f.write('#EXTM3U\n') f.write(f'#EXT-X-TARGETDURATION:{seconds}\n') f.write('#EXT-X-VERSION:3\n') f.write('#EXT-X-PLAYLIST-TYPE:VOD\n') for index, milliseconds in enumerate(durations): f.write(f'#EXTINF:{milliseconds / 1000:.5f},\n') f.write(f'fileSequence{index}.webvtt\n') f.write('#EXT-X-ENDLIST\n') glut23-webvtt-py-cca74e4/webvtt/srt.py000066400000000000000000000334631527171777000200110ustar00rootroot00000000000000"""SRT format module.""" import typing import re from .models import ( Caption, Style, _caption_from_timings, _iter_cue_text_tokens ) from .errors import MalformedFileError from . import utils _TIMESTAMP = r'[0-9]+:[0-9]{1,2}:[0-9]{1,2}[,.][0-9]+' _CUE_CLASS_SELECTOR = re.compile(r'::cue\(\s*\.([\w-]+)\s*\)') # the cue tags that SRT players support _SRT_TAGS = {'b', 'i', 'u'} class SRTCueBlock: """Representation of a cue timing block.""" CUE_TIMINGS_PATTERN = re.compile( rf'\s*({_TIMESTAMP})\s*-->\s*({_TIMESTAMP})' ) def __init__( self, index: typing.Optional[str], start: str, end: str, payload: typing.Sequence[str] ): """ Initialize. :param index: cue index, or None for a cue without an index :param start: start time :param end: end time :param payload: caption text """ self.index = index self.start = start self.end = end self.payload = payload @classmethod def is_valid( cls, lines: typing.Sequence[str] ) -> bool: """ Validate the lines for a match of a cue time block. The cue index is optional, as most SRT players read cues without one. :param lines: the lines to be validated :returns: true for a matching cue time block """ timings = _timings_line(lines) return bool( len(lines) > timings and re.match(cls.CUE_TIMINGS_PATTERN, lines[timings]) ) @classmethod def from_lines( cls, lines: typing.Sequence[str] ) -> 'SRTCueBlock': """ Create a `SRTCueBlock` from lines of text. :param lines: the lines of text :returns: `SRTCueBlock` instance """ timings = _timings_line(lines) index = lines[0] if timings else None match = re.match(cls.CUE_TIMINGS_PATTERN, lines[timings]) assert match is not None payload = lines[timings + 1:] return cls(index, match.group(1), match.group(2), payload) def parse(lines: typing.Sequence[str]) -> typing.List[Caption]: """ Parse SRT captions from lines of text. :param lines: lines of text :returns: list of `Caption` objects :raises MalformedFileError: if the content is not in SRT format """ if not is_valid_content(lines): raise MalformedFileError('Invalid format') return parse_captions(lines) def is_valid_content(lines: typing.Sequence[str]) -> bool: """ Validate lines of text for valid SRT content. The content is valid when it starts with a cue index and the cue timings, after any blank lines. The first cue can have no text. :param lines: lines of text :returns: true for a valid SRT content """ start = next((i for i, line in enumerate(lines) if line.strip()), len(lines)) return bool( len(lines) >= start + 2 and _is_index(lines[start]) and '-->' in lines[start + 1] ) def _is_cue_start(lines: typing.Sequence[str]) -> bool: """ Validate if the lines start with cue timings or a cue index and timings. A line with only digits followed by caption text is not a cue start, as caption text can have such a line, e.g. a year. :param lines: the lines to be validated :returns: true if the lines start a cue """ return '-->' in lines[0] or ( len(lines) > 1 and _is_index_and_timings(lines[0], lines[1]) ) def _is_index(line: str) -> bool: """Return whether a line can be a cue index, a line with only digits.""" line = line.strip() return line.isascii() and line.isdigit() def _timings_line(lines: typing.Sequence[str]) -> int: """Return the position of the cue timings, after the index if any.""" return 1 if lines and _is_index(lines[0]) else 0 def _is_index_and_timings(line: str, next_line: str) -> bool: """Return whether a line is a cue index followed by the cue timings.""" return _is_index(line) and '-->' in next_line def _iter_cue_blocks( lines: typing.Sequence[str] ) -> typing.Generator[typing.List[str], None, None]: """ Iterate blocks of text, also splitting a block where a cue starts. A cue starts at a cue index followed by the cue timings, even without an empty line before it. A block with only a cue index followed by a block starting with the cue timings is joined with it, as the index of that cue. :param lines: lines of text """ index_block: typing.Optional[typing.List[str]] = None for block_lines in _iter_split_blocks(lines): if index_block is not None: if '-->' in block_lines[0]: block_lines = index_block + block_lines else: yield index_block index_block = None if len(block_lines) == 1 and _is_index(block_lines[0]): index_block = block_lines else: yield block_lines if index_block is not None: yield index_block def _iter_split_blocks( lines: typing.Sequence[str] ) -> typing.Generator[typing.List[str], None, None]: """ Iterate blocks of text, splitting a block where a cue index follows. :param lines: lines of text """ for block_lines in utils.iter_blocks_of_lines(lines): start = 0 for i in range(1, len(block_lines) - 1): if _is_index_and_timings(block_lines[i], block_lines[i + 1]): yield block_lines[start:i] start = i yield block_lines[start:] def parse_captions(lines: typing.Sequence[str]) -> typing.List[Caption]: """ Parse captions from the text. Blocks of text that do not start a cue are taken as part of the text of the previous cue, as some files have empty lines in the caption text. :param lines: lines of text :returns: list of `Caption` objects """ captions: typing.List[Caption] = [] caption: typing.Optional[Caption] = None for block_lines in _iter_cue_blocks(lines): if _is_cue_start(block_lines): caption = None if SRTCueBlock.is_valid(block_lines): cue_block = SRTCueBlock.from_lines(block_lines) caption = _caption_from_timings(cue_block.start, cue_block.end, cue_block.payload ) if caption is not None: captions.append(caption) elif caption is not None: caption.lines.extend(block_lines) return [caption for caption in captions if caption.lines] def _strip_css_comments(css: str) -> str: """ Remove the comments from CSS. A comment without an end runs to the end of the CSS. This scans for the comments with ``str.find``, as a non-greedy regex for them rescans the rest of the CSS from every comment start without an end, which is quadratic in the length of the CSS. :param css: the CSS text :returns: the CSS without comments """ parts = [] pos = 0 while True: start = css.find('/*', pos) if start < 0: break parts.append(css[pos:start]) end = css.find('*/', start + 2) if end < 0: return ''.join(parts) pos = end + 2 parts.append(css[pos:]) return ''.join(parts) def _css_colour(declarations: str) -> typing.Optional[str]: """ Return the colour in the declarations of a CSS rule. The last ``color`` declaration applies. ``!important`` is removed, and a value that cannot be written in a ``font`` tag is ignored. :param declarations: the declarations of the rule :returns: the colour, or None when the rule has no colour """ colour = None for declaration in declarations.split(';'): name, _, value = declaration.partition(':') value = value.partition('!')[0].strip() if (name.strip().lower() == 'color' and value and not any(char in value for char in '"<>')): colour = value return colour def _cue_colours( styles: typing.Iterable[Style] ) -> typing.Dict[str, typing.Tuple[int, str]]: """ Return the colours of the cue classes defined in the styles. Only the ``color`` of ``::cue(.class)`` selectors is read. Each class is mapped to the colour and the position of its rule, as when a cue has more than one class with a colour, the rule that comes last applies. :param styles: Iterable of `Style` objects :returns: a dict of class name to (rule position, colour) """ colours = {} position = 0 for style in styles: # split the rules on braces, as a regex for them is quadratic on # text without braces for rule in _strip_css_comments(style.text).split('}'): selectors, _, declarations = rule.rpartition('{') colour = _css_colour(declarations) if colour is None: continue position += 1 for selector in selectors.split(','): match = _CUE_CLASS_SELECTOR.fullmatch(selector.strip()) if match: colours[match.group(1)] = (position, colour) return colours def _srt_tags( tag: str, colours: typing.Mapping[str, typing.Tuple[int, str]] ) -> typing.Optional[typing.Tuple[str, str, str]]: """ Return the SRT tags for a cue start tag. :param tag: the content of the cue start tag :param colours: the colours of the cue classes :returns: the tag name and the SRT start and end tags, which are empty when SRT has no tag for it, or None for a timestamp tag """ name, *classes = (tag.split(maxsplit=1) or [''])[0].split('.') # timestamp tags have no name if not name.isalpha(): return None # font tags of captions read from SRT are kept as they are if name == 'font': return name, f'<{tag}>', '' start_tags = end_tags = '' if name in _SRT_TAGS: start_tags, end_tags = f'<{name}>', f'' matches = [colours[cls] for cls in classes if cls in colours] if matches: start_tags += f'' end_tags = '' + end_tags return name, start_tags, end_tags def _close_tags( open_tags: typing.List[typing.Tuple[str, str]], name: str ) -> typing.List[str]: """ Close the last open tag with a name and the tags opened after it. :param open_tags: the names and SRT end tags of the open tags, which are removed when closed :param name: the name of the cue end tag :returns: the SRT end tags, or none when no tag with the name is open """ names = [open_name for open_name, _ in open_tags] if name not in names: return [] index = len(names) - 1 - names[::-1].index(name) closed = open_tags[index:] del open_tags[index:] return [end_tags for _, end_tags in reversed(closed)] def _format_cue_text( text: str, colours: typing.Mapping[str, typing.Tuple[int, str]] ) -> str: """ Convert the cue tags of a cue text to SRT tags. Bold, italic, underline and ``font`` tags are kept, and a class with a colour adds a ``font`` tag with that colour. Other tags are removed, keeping their text, and the tags left open at the end of the text are closed. Tags are found in each line, as when the tags are removed from the text, but stay open across lines. :param text: the cue text :param colours: the colours of the cue classes :returns: the text with SRT tags """ lines = [] open_tags: typing.List[typing.Tuple[str, str]] = [] for line in text.split('\n'): output = [] for token, is_tag in _iter_cue_text_tokens(line): if not is_tag: output.append(token) elif token.startswith('/'): output.extend(_close_tags(open_tags, token[1:].strip())) else: tags = _srt_tags(token, colours) if tags is not None: name, start_tags, end_tags = tags output.append(start_tags) open_tags.append((name, end_tags)) lines.append(''.join(output)) lines[-1] += ''.join(end_tags for _, end_tags in reversed(open_tags)) return '\n'.join(lines) def write( f: typing.IO[str], captions: typing.Iterable[Caption], styles: typing.Optional[typing.Iterable[Style]] = None ): """ Write captions to an output. Captions without text, such as text of blank lines or only of cue tags, are left out, as a cue without text cannot be read back from SRT, and the others are numbered consecutively. :param f: file or file-like object :param captions: Iterable of `Caption` objects :param styles: Iterable of `Style` objects to keep the formatting of the cue text, or None to write the text without cue tags """ colours = None if styles is None else _cue_colours(styles) output = [] index = 0 for caption in captions: # the text without cue tags, so tags with no text are left out also # when keeping the formatting if not caption.text.strip(): continue text = (caption.text if colours is None else _format_cue_text(caption.raw_text, colours)) lines = [line for line in text.split('\n') if line.strip()] index += 1 output.extend([ f'{index}', '{} --> {}'.format(*map(lambda x: x.replace('.', ','), (caption.start, caption.end)) ), *lines, '' ]) f.write('\n'.join(output)) glut23-webvtt-py-cca74e4/webvtt/utils.py000066400000000000000000000113151527171777000203310ustar00rootroot00000000000000"""Utils module.""" import locale import os import re import typing import codecs CODEC_BOMS = { 'utf-8': codecs.BOM_UTF8, 'utf-32-le': codecs.BOM_UTF32_LE, 'utf-32-be': codecs.BOM_UTF32_BE, 'utf-16-le': codecs.BOM_UTF16_LE, 'utf-16-be': codecs.BOM_UTF16_BE } # the codecs that always write a Byte Order Mark _BOM_WRITING_CODECS = ('utf-8-sig', 'utf-16', 'utf-32') # the line terminators of WebVTT, characters like U+2028 do not break lines _LINE_TERMINATOR = re.compile(r'\r\n|\r|\n') def _split_lines(text: str) -> typing.List[str]: """ Split text into lines on CR, LF and CRLF. Works like ``str.splitlines`` but only breaks lines on the WebVTT line terminators, other characters like U+2028 or form feed stay in the line. :param text: the text to split :returns: the list of lines, without a final empty line when the text ends with a line terminator """ lines = _LINE_TERMINATOR.split(text) if not lines[-1]: lines.pop() return lines class FileWrapper: """File handling functionality with built-in support for Byte OrderMark.""" def __init__( self, file_path: str, mode: typing.Optional[str] = None, encoding: typing.Optional[str] = None ): """ Initialize. :param file_path: path to the file :param mode: mode in which the file is opened :param encoding: name of the encoding used to decode the file """ self.file_path = file_path self.mode = mode or 'r' self.bom_encoding = self.detect_bom_encoding(file_path) self.encoding = (self.bom_encoding or encoding or 'utf-8' ) @classmethod def open( cls, file_path: str, mode: typing.Optional[str] = None, encoding: typing.Optional[str] = None ) -> 'FileWrapper': """ Open a file. :param file_path: path to the file :param mode: mode in which the file is opened :param encoding: name of the encoding used to decode the file """ return cls(file_path, mode, encoding) def __enter__(self): """Enter context.""" self.file = open( file=self.file_path, mode=self.mode, encoding=self.encoding ) if self.bom_encoding: self.file.seek(len(CODEC_BOMS[self.bom_encoding])) return self def __exit__(self, exc_type, exc_val, exc_tb): """Exit context.""" self.file.close() @staticmethod def detect_bom_encoding(file_path: str) -> typing.Optional[str]: """ Detect the encoding of a file based on the presence of the BOM. :param file_path: path to the file :returns: the encoding if BOM is found or None. """ with open(file_path, mode='rb') as f: return _bom_encoding(f.read(4)) def _bom_encoding(data: bytes) -> typing.Optional[str]: """ Return the encoding of the BOM at the start of the data. :param data: the data, or its first bytes :returns: the encoding if BOM is found or None. """ for encoding, bom in CODEC_BOMS.items(): if data.startswith(bom): return encoding return None def _write_text_file( file_path: str, text: str, encoding: typing.Optional[str] = None ): """ Write text to a file, encoding all of it before the file is opened. An error while encoding, such as a character the encoding cannot write or an unknown encoding, is raised before the file is opened, so an existing file is left unchanged. Line feeds are written as the line separator of the platform, as in a file opened in text mode. :param file_path: path to the file :param text: the text to write :param encoding: name of the encoding used to encode the text, the preferred encoding of the locale if None, as in a file opened in text mode """ if encoding is None: encoding = locale.getpreferredencoding(False) data = text.replace('\n', os.linesep).encode(encoding) with open(file_path, 'wb') as f: f.write(data) def iter_blocks_of_lines( lines: typing.Iterable[str] ) -> typing.Generator[typing.List[str], None, None]: """ Iterate blocks of text. :param lines: lines of text. """ current_text_block = [] for line in lines: if line.strip(): current_text_block.append(line) elif current_text_block: yield current_text_block current_text_block = [] if current_text_block: yield current_text_block glut23-webvtt-py-cca74e4/webvtt/vtt.py000066400000000000000000000702241527171777000200120ustar00rootroot00000000000000"""VTT format module.""" import re import typing from dataclasses import dataclass, field from .errors import ( MalformedFileError, MalformedCaptionError, MalformedRegionError ) from .models import Caption, CueSettings, Region, Style from .utils import _split_lines _TIMESTAMP = r'(?:[0-9]+:)?[0-9]{1,2}:[0-9]{1,2}\.[0-9]{1,3}' _STRICT_TIMESTAMP = r'(?:[0-9]{2,}:)?[0-9]{2}:[0-9]{2}\.[0-9]{3}' @dataclass class ParserOutput: """Output of parser.""" styles: typing.List[Style] captions: typing.List[Caption] header_comments: typing.List[str] footer_comments: typing.List[str] header_text: str = '' metadata: typing.List[str] = field(default_factory=list) regions: typing.List[Region] = field(default_factory=list) @classmethod def from_data( cls, data: typing.Mapping[str, typing.Any] ) -> 'ParserOutput': """ Return a `ParserOutput` instance from the provided data. :param data: data from the parser :returns: an instance of `ParserOutput` """ items = data.get('items', []) return cls( captions=[it for it in items if isinstance(it, Caption)], styles=[it for it in items if isinstance(it, Style)], regions=[it for it in items if isinstance(it, Region)], header_comments=data.get('header_comments', []), footer_comments=data.get('footer_comments', []) ) class WebVTTCueBlock: """Representation of a cue timing block.""" CUE_TIMINGS_PATTERN = re.compile( rf'\s*({_TIMESTAMP})\s*-->\s*({_TIMESTAMP})' ) STRICT_CUE_TIMINGS_PATTERN = re.compile( rf'\s*({_STRICT_TIMESTAMP})\s*-->\s*({_STRICT_TIMESTAMP})(?=\s|$)' ) def __init__( self, identifier, start, end, payload, settings='' ): """ Initialize. :param start: start time :param end: end time :param payload: caption text :param settings: cue settings following the end time """ self.identifier = identifier self.start = start self.end = end self.payload = payload self.settings = settings @classmethod def is_valid( cls, lines: typing.Sequence[str], strict: bool = False ) -> bool: """ Validate the lines for a match of a cue time block. Timestamps with fields shorter than the WebVTT format requires, as in ``0:0:13.540``, are accepted unless strict. :param lines: the lines to be validated :param strict: only accept timestamps in the WebVTT format :returns: true for a matching cue time block """ pattern = ( cls.STRICT_CUE_TIMINGS_PATTERN if strict else cls.CUE_TIMINGS_PATTERN ) return bool( ( len(lines) >= 1 and re.match(pattern, lines[0]) and (len(lines) < 2 or "-->" not in lines[1]) ) or ( len(lines) >= 2 and "-->" not in lines[0] and re.match(pattern, lines[1]) and (len(lines) < 3 or "-->" not in lines[2]) ) ) @classmethod def from_lines( cls, lines: typing.Iterable[str] ) -> 'WebVTTCueBlock': """ Create a `WebVTTCueBlock` from lines of text. :param lines: the lines of text :returns: `WebVTTCueBlock` instance """ identifier = None start = None end = None settings = '' payload = [] for line in lines: timing_match = re.match(cls.CUE_TIMINGS_PATTERN, line) if timing_match: start = timing_match.group(1) end = timing_match.group(2) settings = cls._settings_after_timings( line[timing_match.end():] ) elif not start: identifier = line else: payload.append(line) return cls(identifier, start, end, payload, settings) @staticmethod def _settings_after_timings(rest: str) -> str: """ Return the cue settings from the rest of a cue timings line. As in WebVTT, the rest of the line is the cue settings, unless it starts with a digit that is part of a malformed end time, such as the last digit of ``00:00:04.3001``, and is then ignored. :param rest: the text of the line following the end time :returns: the cue settings """ return '' if rest[:1].isdecimal() else rest @staticmethod def format_lines(caption: Caption) -> typing.List[str]: """ Return the lines for a cue block. :param caption: the `Caption` instance :returns: list of lines for a cue block """ return [ '', *(identifier for identifier in {caption.identifier} if identifier), _format_timings(caption), *caption.lines ] def _format_timings(caption: Caption) -> str: """ Return the cue timings line of a caption. :param caption: the `Caption` instance :returns: the start and end times followed by the cue settings, if any """ timings = f'{caption.start} --> {caption.end}' return f'{timings} {caption.settings}' if caption.settings else timings class WebVTTCommentBlock: """Representation of a comment block.""" COMMENT_PATTERN = re.compile(r'NOTE\s(.*?)\Z', re.DOTALL) def __init__(self, text: str): """ Initialize. :param text: comment text """ self.text = text @classmethod def is_valid( cls, lines: typing.Sequence[str] ) -> bool: """ Validate the lines for a match of a comment block. A comment starts with ``NOTE`` followed by a space, a tab or the end of the line, and no line contains ``-->``, as WebVTT reads a line with ``-->`` as cue timings. :param lines: the lines to be validated :returns: true for a matching comment block """ return bool( lines and _starts_with_keyword(lines[0], 'NOTE') and not _has_arrow(lines) ) @classmethod def from_lines( cls, lines: typing.Iterable[str] ) -> 'WebVTTCommentBlock': """ Create a `WebVTTCommentBlock` from lines of text. :param lines: the lines of text :returns: `WebVTTCommentBlock` instance """ match = cls.COMMENT_PATTERN.match('\n'.join(lines)) return cls(text=match.group(1).strip() if match else '') @staticmethod def format_lines(lines: str) -> typing.List[str]: """ Return the lines for a comment block. :param lines: comment lines :returns: list of lines for a comment block """ list_of_lines = _split_lines(lines) or [''] if len(list_of_lines) == 1: return [f'NOTE {list_of_lines[0]}'] return ['NOTE', *list_of_lines] class WebVTTStyleBlock: """Representation of a style block.""" STYLE_PATTERN = re.compile(r'STYLE\s(.*?)\Z', re.DOTALL) def __init__(self, text: str): """ Initialize. :param text: style text """ self.text = text @classmethod def is_valid( cls, lines: typing.Sequence[str] ) -> bool: """ Validate the lines for a match of a style block. The first line is ``STYLE``, optionally followed by whitespace, and the style sheet follows in the next lines. :param lines: the lines to be validated :returns: true for a matching style block """ return (len(lines) >= 2 and _is_keyword_line(lines[0], 'STYLE') and not any(not line or '-->' in line for line in lines) ) @classmethod def from_lines( cls, lines: typing.Iterable[str] ) -> 'WebVTTStyleBlock': """ Create a `WebVTTStyleBlock` from lines of text. :param lines: the lines of text :returns: `WebVTTStyleBlock` instance """ match = cls.STYLE_PATTERN.match('\n'.join(lines)) return cls(text=match.group(1).strip() if match else '') @staticmethod def format_lines(lines: typing.List[str]) -> typing.List[str]: """ Return the lines for a style block. :param lines: style lines :returns: list of lines for a style block """ return ['STYLE', *lines] class WebVTTRegionBlock: """Representation of a region block.""" @staticmethod def is_valid(lines: typing.Sequence[str]) -> bool: """ Validate the lines for a match of a region block. The first line is ``REGION``, optionally followed by whitespace, and the settings of the region follow in the next lines. :param lines: the lines to be validated :returns: true for a matching region block """ return bool( lines and _is_keyword_line(lines[0], 'REGION') and not _has_arrow(lines) ) @staticmethod def format_lines(region: Region) -> typing.List[str]: """ Return the lines for a region block. :param region: the `Region` instance :returns: list of lines for a region block, with a setting per line """ return ['REGION', *region] def parse( lines: typing.Sequence[str], strict: bool = False ) -> ParserOutput: """ Parse VTT captions from lines of text. :param lines: lines of text :param strict: raise an error on blocks that are not valid WebVTT instead of ignoring them :returns: object `ParserOutput` with all parsed items :raises MalformedFileError: if the content is not in WebVTT format, or if strict and "WEBVTT" is not followed by a space, a tab or the end of the first line :raises MalformedCaptionError: if strict and a block is not valid WebVTT """ if not is_valid_content(lines): raise MalformedFileError('Invalid format') if strict and not _starts_with_keyword(lines[0], 'WEBVTT'): raise MalformedFileError(f'Invalid header {lines[0]!r}') output = parse_items(lines, strict) output.header_text, output.metadata = _parse_header( next(_iter_blocks_of_lines(lines)) ) return output def is_valid_content(lines: typing.Sequence[str]) -> bool: """ Validate lines of text for valid VTT content. :param lines: lines of text :returns: true for a valid VTT content """ return bool(lines and lines[0].startswith('WEBVTT')) def _iter_blocks_of_lines( lines: typing.Iterable[str], strict: bool = False ) -> typing.Generator[typing.List[str], None, None]: """ Iterate blocks of text following the WebVTT parsing rules. A block ends at an empty line, so lines containing only whitespace are part of the block. A line containing "-->" that cannot be the cue timings line of the current block starts a new block. :param lines: lines of text :param strict: raise an error when a block is not ended by an empty line before a cue timings line :raises MalformedCaptionError: if strict and a cue timings line follows a block without an empty line in between """ block: typing.List[str] = [] in_header = True for line in lines: if not line or _starts_new_block(block, line, in_header): if strict and line and not in_header: raise MalformedCaptionError( f'Invalid cue block {[*block, line]!r}' ) if block: yield block block = [] in_header = False if line: block.append(line) if block: yield block def _starts_new_block( block: typing.Sequence[str], line: str, in_header: bool ) -> bool: """ Return whether a line containing "-->" starts a new block. The line can only be the cue timings line of the current block when it is the block's first line, or the second one following an identifier. :param block: lines of the current block :param line: the next line of text :param in_header: true if the current block is the file header :returns: true if the line starts a new block """ return bool( block and '-->' in line and (in_header or len(block) > 1 or '-->' in block[0]) ) def _has_arrow(lines: typing.Iterable[str]) -> bool: """Return whether a line contains the cue timings arrow ``-->``.""" return any('-->' in line for line in lines) def _starts_with_keyword(line: str, keyword: str) -> bool: """ Return whether a line is a keyword, alone or followed by a space or tab. :param line: the first line of a block :param keyword: the keyword, such as ``WEBVTT`` or ``NOTE`` :returns: true if the line is the keyword, or the keyword followed by a space or a tab and any text """ return line.startswith(keyword) and line[len(keyword):][:1] in ( '', ' ', '\t' ) def _is_keyword_line(line: str, keyword: str) -> bool: """ Return whether a line is a block keyword, optionally followed by spaces. :param line: the first line of a block :param keyword: the keyword, such as ``STYLE`` or ``REGION`` :returns: true if the line is the keyword followed only by whitespace """ return (line.startswith(keyword) and not line[len(keyword):].strip(' \t\x0c')) def parse_items( lines: typing.Sequence[str], strict: bool = False ) -> ParserOutput: """ Parse items from the text. The lines can start with the header block, which is not an item. :param lines: lines of text :param strict: raise an error on blocks that are not valid WebVTT instead of ignoring them :returns: an object `ParserOutput` with all parsed items :raises MalformedCaptionError: if strict and the header contains "-->", or another block is not a valid cue, comment, style or region """ header_comments: typing.List[str] = [] items: typing.List[typing.Union[Caption, Style, Region]] = [] comments: typing.List[WebVTTCommentBlock] = [] seen_cue = False for index, block_lines in enumerate(_iter_blocks_of_lines(lines, strict)): if _is_header(index, block_lines, strict): continue item = parse_item(block_lines, strict, seen_cue) if not seen_cue: seen_cue = _has_arrow(block_lines) if item: item.comments = [comment.text for comment in comments] comments = [] items.append(item) elif WebVTTCommentBlock.is_valid(block_lines): comments.append(WebVTTCommentBlock.from_lines(block_lines)) elif strict: raise _invalid_block_error(block_lines) if items: header_comments, items[0].comments = items[0].comments, header_comments return ParserOutput.from_data( {'items': items, 'header_comments': header_comments, 'footer_comments': [comment.text for comment in comments] } ) def _is_header( index: int, lines: typing.Sequence[str], strict: bool = False ) -> bool: """ Return whether a block is the header block of the file. :param index: the position of the block in the file :param lines: the lines of the block :param strict: raise an error if the header contains "-->" :returns: true for the first block when it starts with "WEBVTT" :raises MalformedCaptionError: if strict and the header contains "-->" """ if index != 0 or not is_valid_content(lines): return False if strict and _has_arrow(lines): raise _invalid_block_error(lines) return True def _invalid_block_error( lines: typing.Sequence[str] ) -> MalformedCaptionError: """ Return the error for a block that is not valid WebVTT. :param lines: the lines of the block :returns: the error, naming the block a cue block if it has "-->" """ kind = 'cue block' if _has_arrow(lines) else 'block' return MalformedCaptionError(f'Invalid {kind} {lines!r}') def _parse_header( lines: typing.Sequence[str] ) -> typing.Tuple[str, typing.List[str]]: """ Parse the header block of the file. :param lines: lines of the header block, the first one starting with "WEBVTT" :returns: the text following "WEBVTT" in the first line, empty if it contains "-->" as WebVTT ignores it and it cannot be written, and the metadata lines that follow it """ text = lines[0][len('WEBVTT'):] if text[:1] in (' ', '\t'): text = text[1:] if '-->' in text: text = '' return text, list(lines[1:]) def _validate_header(header_text: str, metadata: typing.Iterable[str]): """ Validate the header text and metadata lines before writing them. :param header_text: the text following "WEBVTT" in the first line :param metadata: the metadata lines of the header :raises ValueError: if the header text contains a line break or "-->", or a metadata line is empty, contains a line break or contains "-->" """ if _has_line_break(header_text) or '-->' in header_text: raise ValueError( f'Header text must be a single line without "-->": ' f'{header_text!r}' ) for line in metadata: if not line or _has_line_break(line) or '-->' in line: raise ValueError( 'Metadata lines must be single non-empty lines without ' f'"-->": {line!r}' ) def _has_line_break(text: str) -> bool: """ Return whether the text contains a WebVTT line terminator. :param text: the text to check :returns: true if the text contains a carriage return or a line feed """ return '\n' in text or '\r' in text def _lines_with_content(text: str) -> typing.List[str]: """ Return the lines of a text up to its last line that is not empty. Empty lines at the end only end the block where the text is written, so they do not change how the text and the blocks after it are read. :param text: the text :returns: the lines up to the last one that is not empty """ lines = _split_lines(text) while lines and not lines[-1]: lines.pop() return lines def _ends_block_early(lines: typing.Sequence[str]) -> bool: """ Return whether lines written in a block would end it or start a cue. :param lines: the lines of the text written in the block :returns: true if a line is empty or contains "-->" """ return not all(lines) or _has_arrow(lines) def _validate_caption(caption: Caption): """ Validate the identifier and text of a caption before writing it. A line break or "-->" in the identifier, or an empty line or "-->" in the text, would end the cue or start another one when it is read. :param caption: the `Caption` instance :raises ValueError: if the identifier contains a line break or "-->", or the text has an empty line or contains "-->" """ identifier = caption.identifier if identifier and (_has_line_break(identifier) or '-->' in identifier): raise ValueError( 'Caption identifiers must be a single line without "-->": ' f'{identifier!r}' ) if _ends_block_early(_lines_with_content(caption.raw_text)): raise ValueError( 'Caption text must not have empty lines or "-->": ' f'{caption.raw_text!r}' ) def _validate_style(style: Style): """ Validate the text of a style before writing it. An empty line would end the style block when it is read, and a style block cannot contain "-->". :param style: the `Style` instance :raises ValueError: if the text has an empty line or contains "-->" """ if _ends_block_early(_lines_with_content(style.text)): raise ValueError( f'Style text must not have empty lines or "-->": {style.text!r}' ) def _validate_comment(comment: str): """ Validate the text of a comment before writing it. An empty line would end the comment block when it is read, and a comment cannot contain "-->", as WebVTT would read it as a cue. :param comment: the text of the comment :raises ValueError: if the text has an empty line or contains "-->" """ lines = _lines_with_content(comment) if not all(lines) or _has_arrow(lines): raise ValueError( f'Comments must not have empty lines or "-->": {comment!r}' ) def parse_item( lines: typing.Sequence[str], strict: bool = False, seen_cue: bool = False ) -> typing.Union[Caption, Style, Region, None]: """ Parse an item from lines of text. :param lines: lines of text :param strict: only accept cue timestamps in the WebVTT format and raise an error for cues with an invalid timestamp or cue setting, or an end time that is not after the start time, for regions with an invalid region setting, and for regions and styles after a cue :param seen_cue: true if a cue came before, so the lines cannot be a region or a style, as they are only defined before the first cue :returns: An item (Caption, Style or Region) if found, otherwise None :raises MalformedCaptionError: if strict and a cue has an invalid timestamp or a cue setting that is not known, not valid or repeated, or an end time that is not after the start time, or a style comes after a cue :raises MalformedRegionError: if strict and a region has a region setting that is not known, not valid or repeated, or comes after a cue """ if WebVTTCueBlock.is_valid(lines, strict): return _parse_cue(lines, strict) if WebVTTRegionBlock.is_valid(lines): if not seen_cue: return Region.from_string(' '.join(lines[1:]), strict) if strict: raise MalformedRegionError(f'Region block after a cue {lines!r}') return None if WebVTTStyleBlock.is_valid(lines): if not seen_cue: return Style(WebVTTStyleBlock.from_lines(lines).text) if strict: raise MalformedCaptionError(f'Style block after a cue {lines!r}') return None return None def _parse_cue( lines: typing.Sequence[str], strict: bool = False ) -> typing.Optional[Caption]: """ Parse a cue from the lines of a cue block. :param lines: the lines of a cue block :param strict: raise an error for an invalid timestamp or cue setting, or an end time that is not after the start time :returns: a `Caption`, or None if the cue is malformed and not strict :raises MalformedCaptionError: if strict and the cue is malformed """ cue_block = WebVTTCueBlock.from_lines(lines) try: caption = Caption(cue_block.start, cue_block.end, cue_block.payload, cue_block.identifier, CueSettings.from_string(cue_block.settings, strict) ) except MalformedCaptionError: if strict: raise return None if strict and caption.end_time <= caption.start_time: raise MalformedCaptionError( f'Cue end time is not after the start time {lines!r}' ) return caption def write( f: typing.IO[str], captions: typing.Iterable[Caption], styles: typing.Iterable[Style], header_comments: typing.Iterable[str], footer_comments: typing.Iterable[str], header_text: str = '', metadata: typing.Iterable[str] = (), regions: typing.Iterable[Region] = () ): """ Write captions to an output. :param f: file or file-like object :param captions: Iterable of `Caption` objects :param styles: Iterable of `Style` objects :param header_comments: the comments for the header :param footer_comments: the comments for the footer :param header_text: the text following "WEBVTT" in the first line :param metadata: the metadata lines of the header :param regions: Iterable of `Region` objects :raises ValueError: if the header text or metadata are not valid, or the text of a caption, style or comment could not be read back """ f.write( to_str(captions, styles, header_comments, footer_comments, header_text, metadata, regions ) ) def to_str( captions: typing.Iterable[Caption], styles: typing.Iterable[Style], header_comments: typing.Iterable[str], footer_comments: typing.Iterable[str], header_text: str = '', metadata: typing.Iterable[str] = (), regions: typing.Iterable[Region] = () ) -> str: """ Convert captions to a string with webvtt format. :param captions: the iterable of `Caption` objects :param styles: the iterable of `Style` objects :param header_comments: the comments for the header :param footer_comments: the comments for the footer :param header_text: the text following "WEBVTT" in the first line :param metadata: the metadata lines of the header :param regions: the iterable of `Region` objects :returns: String of the content in WebVTT format. :raises ValueError: if the header text or metadata are not valid, or the text of a caption, style or comment could not be read back """ return _to_str(captions, styles, header_comments, footer_comments, header_text, metadata, regions ) def _to_str( captions: typing.Iterable[Caption], styles: typing.Iterable[Style], header_comments: typing.Iterable[str], footer_comments: typing.Iterable[str], header_text: str = '', metadata: typing.Iterable[str] = (), regions: typing.Iterable[Region] = (), item_comments: bool = True ) -> str: """ Convert captions to a string with webvtt format, as `to_str`. :param item_comments: write the comments of the captions, styles and regions, false to leave them out as in the segments for HLS :raises ValueError: if the header text or metadata are not valid, or the text of a caption, style or comment could not be read back """ metadata = list(metadata) _validate_header(header_text, metadata) output = [f'WEBVTT {header_text}' if header_text else 'WEBVTT', *metadata ] output.extend(_format_comments(header_comments)) for region in regions: if item_comments: output.extend(_format_comments(region.comments)) output.extend(['', *WebVTTRegionBlock.format_lines(region)]) for style in styles: _validate_style(style) if item_comments: output.extend(_format_comments(style.comments)) output.extend(['', *WebVTTStyleBlock.format_lines(style.lines)]) for caption in captions: _validate_caption(caption) if item_comments: output.extend(_format_comments(caption.comments)) output.extend(WebVTTCueBlock.format_lines(caption)) if not footer_comments: output.append('') output.extend(_format_comments(footer_comments)) return '\n'.join(output) def _format_comments(comments: typing.Iterable[str]) -> typing.List[str]: """ Return the lines for comment blocks, each preceded by an empty line. :param comments: the text of the comments :returns: list of lines for the comment blocks :raises ValueError: if the text of a comment could not be read back """ lines = [] for comment in comments: _validate_comment(comment) lines.extend(['', *WebVTTCommentBlock.format_lines(comment)]) return lines glut23-webvtt-py-cca74e4/webvtt/webvtt.py000066400000000000000000000731561527171777000205170ustar00rootroot00000000000000"""WebVTT module.""" import codecs import datetime import os import io import typing import warnings from . import vtt, utils from . import srt from . import sbv from .models import ( Caption, Region, Style, Timestamp, _FrameRate, _Position, _offset_in_milliseconds, _retime_captions, _sync_mapping, _framerate_mapping ) from .errors import MissingFilenameError DEFAULT_ENCODING = 'utf-8' _T = typing.TypeVar('_T') # the extensions of each format, kept when saving a file to the same format _FORMAT_EXTENSIONS = {'vtt': ('.vtt', '.webvtt'), 'srt': ('.srt',)} class WebVTT: """ Parse captions in WebVTT format and also from other formats like SRT. To read WebVTT: WebVTT.read('captions.vtt') For other formats: WebVTT.from_srt('captions.srt') WebVTT.from_sbv('captions.sbv') """ def __init__( self, # NoReturn, so type checkers report positional arguments too *args: typing.NoReturn, file: typing.Optional[str] = None, captions: typing.Optional[typing.List[Caption]] = None, styles: typing.Optional[typing.List[Style]] = None, header_comments: typing.Optional[typing.List[str]] = None, footer_comments: typing.Optional[typing.List[str]] = None, header_text: typing.Optional[str] = None, metadata: typing.Optional[typing.List[str]] = None, regions: typing.Optional[typing.List[Region]] = None ): """ Initialize. The arguments are keyword only, and no file is read: use `read`, `from_srt`, `from_sbv`, `from_buffer` or `from_string` to read captions. The ``file`` attribute is the path the captions were read from or last saved to, which `save` uses when no output is given, and can be set to choose where they are saved. The lists are kept as the attributes, not copied, so changes to a list after creating the instance show in it. :param file: DEPRECATED, set the ``file`` attribute instead :param captions: the list of captions :param styles: the list of styles :param header_comments: list of comments for the start of the file :param footer_comments: list of comments for the bottom of the file :param header_text: the text following "WEBVTT" in the first line :param metadata: list of metadata lines following the "WEBVTT" line, e.g. ``X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:0`` :param regions: the list of regions :raises TypeError: if an argument is passed by position """ if args: # WebVTT('captions.vtt') looks like it reads the file, but it # only set the path where save() writes, so saving it replaced # the file with no captions raise TypeError( f'{self.__class__.__name__}() takes keyword arguments only ' 'and does not read files, use webvtt.read() to read ' 'captions from a file' ) if file is not None: warnings.warn( f'The file argument of {self.__class__.__name__}() is ' 'deprecated, use webvtt.read() to read captions from a file ' 'and set the file attribute, or pass the output to save(), ' 'to choose where they are saved', DeprecationWarning, stacklevel=2 ) self.file = file self.captions = _list_or_new(captions) self.styles = _list_or_new(styles) self.header_comments = _list_or_new(header_comments) self.footer_comments = _list_or_new(footer_comments) self.header_text = header_text or '' self.metadata = _list_or_new(metadata) self.regions = _list_or_new(regions) self._has_bom = False self.encoding = DEFAULT_ENCODING # the encoding given to read SRT, which save_as_srt keeps self._srt_encoding: typing.Optional[str] = None def __len__(self): """Return the number of captions.""" return len(self.captions) def __getitem__(self, index): """Return a caption by index.""" return self.captions[index] def __repr__(self): """Return the string representation of the WebVTT file.""" return (f'<{self.__class__.__name__} file={self.file!r} ' f'encoding={self.encoding!r}>' ) def __str__(self): """Return a readable representation of the WebVTT content.""" return '\n'.join(str(c) for c in self.captions) @classmethod def read( cls, file: str, encoding: typing.Optional[str] = None, strict: bool = False ) -> 'WebVTT': """ Read a WebVTT captions file. :param file: the file path :param encoding: encoding of a file without a Byte Order Mark, UTF-8 by default. It is only used for reading, as WebVTT files are saved in UTF-8 unless another encoding is given :param strict: raise an error on blocks that are not valid WebVTT instead of ignoring them :returns: a `WebVTT` instance :raises MalformedFileError: if the file is not in the right format :raises MalformedCaptionError: if a caption has an invalid timestamp, or if strict and a block is not valid WebVTT """ with utils.FileWrapper.open(file, encoding=encoding) as fw: instance = cls.from_buffer(fw.file, strict=strict) if fw.bom_encoding: instance.encoding = fw.bom_encoding instance._has_bom = True return instance @classmethod def read_buffer( cls, buffer: typing.Iterator[str] ) -> 'WebVTT': """ Read WebVTT captions from a file-like object. This method is DEPRECATED. Use from_buffer instead. Such file-like object may be the return of an io.open call, io.StringIO object, tempfile.TemporaryFile object, etc. :param buffer: the file-like object to read captions from :returns: a `WebVTT` instance """ warnings.warn( 'Deprecated: use from_buffer instead.', DeprecationWarning, stacklevel=2 ) return cls.from_buffer(buffer) @classmethod def from_buffer( cls, buffer: typing.Union[typing.Iterable[str], typing.BinaryIO], format: str = 'vtt', strict: bool = False, encoding: typing.Optional[str] = None ) -> 'WebVTT': """ Read WebVTT captions from a file-like object. Such file-like object may be the return of an io.open call, in text or binary mode, io.StringIO or io.BytesIO object, tempfile.TemporaryFile object, etc. Binary data is decoded with the encoding of its Byte Order Mark, if any, or else with ``encoding``. The ``name`` of the file-like object is kept as the ``file`` to save to when it is a path, so a file-like object without one, such as standard input, a file opened from a file descriptor or an io.BytesIO object, gives captions without a file, as when they are not read from a file. :param buffer: the file-like object to read captions from, or an iterable of lines :param format: the format of the data (vtt, srt or sbv) :param strict: raise an error on blocks that are not valid WebVTT instead of ignoring them, only for the vtt format, as SRT and SBV have no strict parsing :param encoding: encoding of binary data without a Byte Order Mark, UTF-8 by default. For the srt format, `save_as_srt` saves in the same encoding :returns: a `WebVTT` instance :raises MalformedFileError: if the data is not in the right format :raises MalformedCaptionError: if a caption has an invalid timestamp, or if strict and a block is not valid WebVTT :raises ValueError: if the format is not supported, or strict is given for the srt or sbv format :raises UnicodeDecodeError: if binary data cannot be decoded :raises TypeError: if the buffer is a string or bytes, use from_string to read a string or io.BytesIO to read bytes """ if format not in ('vtt', 'srt', 'sbv'): raise ValueError(f'Format {format} is not supported.') if strict and format != 'vtt': raise ValueError( f'Strict parsing is not supported for the {format} format' ) file = _buffer_file(buffer) lines, bom_encoding, binary = cls._read_buffer(buffer, encoding) if format == 'vtt': output = vtt.parse(cls._get_lines(lines), strict) instance = cls( captions=output.captions, styles=output.styles, header_comments=output.header_comments, footer_comments=output.footer_comments, header_text=output.header_text, metadata=output.metadata, regions=output.regions ) elif format == 'srt': instance = cls(captions=srt.parse(cls._get_lines(lines))) else: instance = cls(captions=sbv.parse(cls._get_lines(lines))) instance.file = file if bom_encoding: instance.encoding = bom_encoding instance._has_bom = True elif format == 'srt' and binary: instance._srt_encoding = encoding return instance @staticmethod def _read_buffer( buffer: typing.Union[typing.Iterable[str], typing.BinaryIO], encoding: typing.Optional[str] ) -> typing.Tuple[ typing.Iterable[str], typing.Optional[str], bool ]: """ Return the lines of a buffer and the encoding of its BOM. A file-like object is read whole, and binary data is decoded with the encoding of its BOM, if any, or else with ``encoding``. :param buffer: the file-like object, or an iterable of lines :param encoding: encoding of binary data without a BOM :returns: the lines, the encoding of the BOM of binary data or None, and whether the data was binary :raises TypeError: if the buffer is a string or bytes """ if isinstance(buffer, str): raise TypeError( 'from_buffer reads a file-like object, not a string: ' 'use from_string to read a string' ) if isinstance(buffer, (bytes, bytearray)): raise TypeError( 'from_buffer reads a file-like object, not bytes: ' 'use io.BytesIO to read bytes' ) read = getattr(buffer, 'read', None) if read is None: return typing.cast(typing.Iterable[str], buffer), None, False data = read() if isinstance(data, str): return [data], None, False bom_encoding = utils._bom_encoding(data) if bom_encoding: data = data[len(utils.CODEC_BOMS[bom_encoding]):] return ( [data.decode(bom_encoding or encoding or 'utf-8')], bom_encoding, True ) @classmethod def from_srt( cls, file: str, encoding: typing.Optional[str] = None ) -> 'WebVTT': """ Read captions from a file in SubRip format. :param file: the file path :param encoding: encoding of a file without a Byte Order Mark, UTF-8 by default. `save_as_srt` saves in the same encoding, while `save` saves WebVTT in UTF-8 unless another encoding is given :returns: a `WebVTT` instance :raises MalformedFileError: if the file is not in the right format :raises MalformedCaptionError: if a caption has an invalid timestamp """ with utils.FileWrapper.open(file, encoding=encoding) as fw: instance = cls.from_buffer(fw.file, format='srt') if not fw.bom_encoding: instance._srt_encoding = encoding return instance @classmethod def from_sbv( cls, file: str, encoding: typing.Optional[str] = None ) -> 'WebVTT': """ Read captions from a file in YouTube SBV format. :param file: the file path :param encoding: encoding of a file without a Byte Order Mark, UTF-8 by default. It is only used for reading, as WebVTT files are saved in UTF-8 unless another encoding is given :returns: a `WebVTT` instance :raises MalformedFileError: if the file is not in the right format :raises MalformedCaptionError: if a caption has an invalid timestamp """ with utils.FileWrapper.open(file, encoding=encoding) as fw: return cls.from_buffer(fw.file, format='sbv') @classmethod def from_string(cls, string: str, strict: bool = False) -> 'WebVTT': """ Read captions from a string. :param string: the captions in a string :param strict: raise an error on blocks that are not valid WebVTT instead of ignoring them :returns: a `WebVTT` instance :raises MalformedFileError: if the string is not in the right format :raises MalformedCaptionError: if a caption has an invalid timestamp, or if strict and a block is not valid WebVTT """ output = vtt.parse(cls._get_lines([string]), strict) return cls( captions=output.captions, styles=output.styles, header_comments=output.header_comments, footer_comments=output.footer_comments, header_text=output.header_text, metadata=output.metadata, regions=output.regions ) @staticmethod def _get_lines(lines: typing.Iterable[str]) -> typing.List[str]: """ Return cleaned lines from an iterable of lines. Each item is split on CR, LF and CRLF, so an item holding several lines, like CR terminated lines read from a buffer that only splits on LF, is also handled. :param lines: iterable of lines :returns: a list of cleaned lines """ cleaned_lines: typing.List[str] = [] for item in lines: # an empty item is an empty line cleaned_lines.extend(utils._split_lines(item) or ['']) if cleaned_lines and cleaned_lines[0].startswith('\ufeff'): cleaned_lines[0] = cleaned_lines[0][1:] return cleaned_lines def _get_destination_file( self, destination_path: typing.Optional[str] = None, extension: str = 'vtt' ) -> str: """ Return the destination file based on the provided params. :param destination_path: optional destination path :param extension: the extension of the file :returns: the destination file :raises MissingFilenameError: if destination path cannot be determined """ if not destination_path and not self.file: raise MissingFilenameError if not destination_path and self.file: destination_path = _with_extension(self.file, extension) assert destination_path is not None target = os.path.join(os.getcwd(), destination_path) if os.path.isdir(target): if not self.file: raise MissingFilenameError # store the file in specified directory new_filename = os.path.basename( _with_extension(self.file, extension) ) return os.path.join(target, new_filename) # a path with an extension is written as given, e.g. .webvtt if not os.path.splitext(target)[1]: target = f'{target}.{extension}' # store the file in the specified full path return target def save( self, output: typing.Optional[str] = None, encoding: typing.Optional[str] = None, add_bom: typing.Optional[bool] = None ): """ Save the WebVTT captions to a file. The content is encoded before the file is opened, so an error while encoding, such as a character the encoding cannot write, leaves an existing file unchanged. :param output: destination path of the file, with ``.vtt`` added when it has no extension, or a folder to save the file there with the name of the file that was read. Without it, the file is saved to the ``file`` attribute, with its extension replaced by ``.vtt`` unless it is ``.vtt`` or ``.webvtt`` :param encoding: encoding of the file, by default the ``encoding`` attribute: the encoding of the Byte Order Mark of the file that was read, or UTF-8 :param add_bom: save the file with Byte Order Mark, or without it if false. By default, the Byte Order Mark of the file that was read is kept :raises MissingFilenameError: if output cannot be determined :raises LookupError: if the encoding is not known :raises ValueError: if the header text or metadata are not valid, or the text of a caption, style or comment could not be read back, or add_bom is true and the encoding has no Byte Order Mark, or false and the encoding always writes one, such as ``utf-16``, leaving an existing file unchanged """ destination = self._get_destination_file(output) # build the content first so an invalid header leaves no file behind content = self.content encoding = encoding or self.encoding content = _byte_order_mark(encoding, add_bom, self._has_bom) + content utils._write_text_file(destination, content, encoding) self.file = destination def save_as_srt( self, output: typing.Optional[str] = None, encoding: typing.Optional[str] = None, keep_formatting: bool = False ): """ Save the WebVTT captions to a file in SubRip format. The content is encoded before the file is opened, so an error while encoding, such as a character the encoding cannot write, leaves an existing file unchanged. :param output: destination path of the file, with ``.srt`` added when it has no extension, or a folder to save the file there with the name of the file that was read. Without it, the file is saved to the ``file`` attribute, with its extension replaced by ``.srt`` unless it is ``.srt`` :param encoding: encoding of the file, by default the encoding given to read the captions from SRT, or UTF-8 :param keep_formatting: keep bold, italic, underline and the colours of the cue classes in the styles, instead of removing the cue tags :raises MissingFilenameError: if output cannot be determined """ destination = self._get_destination_file(output, extension='srt') content = io.StringIO() srt.write(content, self.captions, self.styles if keep_formatting else None ) utils._write_text_file( destination, content.getvalue(), encoding or self._srt_encoding or DEFAULT_ENCODING ) self.file = destination def write( self, f: typing.IO[str], format: str = 'vtt', keep_formatting: bool = False ): """ Save the WebVTT captions to a file-like object. :param f: destination file-like object :param format: the format to use (`vtt` or `srt`) :param keep_formatting: when writing SRT, keep bold, italic, underline and the colours of the cue classes in the styles, instead of removing the cue tags :raises ValueError: if the format is not supported, or when writing WebVTT, the header text or metadata are not valid or the text of a caption, style or comment could not be read back """ if format == 'vtt': return vtt.write(f, self.captions, self.styles, self.header_comments, self.footer_comments, self.header_text, self.metadata, self.regions ) if format == 'srt': return srt.write(f, self.captions, self.styles if keep_formatting else None ) raise ValueError(f'Format {format} is not supported.') def iter_slice( self, start: typing.Optional[str] = None, end: typing.Optional[str] = None ) -> typing.Generator[Caption, None, None]: """ Iterate a slice of the captions based on a time range. Only the captions within the range are returned: a caption that starts before ``start`` or ends after ``end`` is left out. :param start: start timestamp of the range :param end: end timestamp of the range :returns: generator of Captions """ start_time = Timestamp.from_string(start) if start else None end_time = Timestamp.from_string(end) if end else None for caption in self.captions: if ( (not start_time or caption.start_time >= start_time) and (not end_time or caption.end_time <= end_time) ): yield caption def shift( self, offset: typing.Union[float, datetime.timedelta], start: typing.Optional[str] = None ): """ Shift the start and end time of the captions. The offset is rounded to the nearest millisecond, halves away from zero, and a timestamp that would fall before zero is set to ``00:00:00.000``. :param offset: a number of seconds or a ``timedelta``, negative values move the captions earlier :param start: only shift the captions starting at or after this timestamp :raises TypeError: if the offset is not a number or a ``timedelta`` :raises ValueError: if the offset is not finite, or would move a time after the largest timestamp, leaving the captions unchanged :raises MalformedCaptionError: if the start timestamp is not valid """ offset_ms = _offset_in_milliseconds(offset) def mapping(milliseconds: int) -> int: return milliseconds + offset_ms _retime_captions(self.iter_slice(start=start), mapping) def sync(self, *points: typing.Sequence[_Position]): """ Synchronize the captions to two or more points. Each point is a ``(from, to)`` pair: the time ``from`` in the captions is moved to ``to``. Times between two points are moved along the line between them, which fixes captions that drift out of sync, and times before the first or after the last point along the line of the outermost points. Start and end times are rounded to the nearest millisecond, halves away from zero, and a time that would fall before zero is set to ``00:00:00.000``. :param points: ``(from, to)`` pairs, each time a timestamp string, a `Timestamp`, a number of seconds or a ``timedelta`` :raises MalformedCaptionError: if a timestamp string is not valid :raises TypeError: if a point is not a pair of supported values :raises ValueError: if there are fewer than two points, two points have the same ``from`` time, the ``to`` times are not in the same order as the ``from`` times, a time is not finite, is before zero or is after the largest timestamp, or a caption would be moved after the largest timestamp, leaving the captions unchanged """ mapping = _sync_mapping(points) _retime_captions(self.captions, mapping) def change_framerate(self, from_fps: _FrameRate, to_fps: _FrameRate): """ Change the timing of the captions from one frame rate to another. Useful when the captions were timed for a video with a different frame rate, e.g. ``change_framerate(25, 23.976)`` for captions of a 25 fps video played with a 23.976 fps one. Times are multiplied by ``from_fps / to_fps`` and rounded to the nearest millisecond, halves away from zero. Frame rates are read as written, and exact rates such as ``Fraction(24000, 1001)`` for NTSC can be given as a ``fractions.Fraction``. :param from_fps: the frame rate the captions are timed for, a number or a ``fractions.Fraction`` :param to_fps: the frame rate of the video, a number or a ``fractions.Fraction`` :raises TypeError: if a frame rate is not a number :raises ValueError: if a frame rate is not finite or not above zero, or a caption would be moved after the largest timestamp, leaving the captions unchanged """ mapping = _framerate_mapping(from_fps, to_fps) _retime_captions(self.captions, mapping) @property def total_length(self): """ Return the total length of the captions in seconds. It is the time from the start of the first caption to the end of the last one, in whole seconds as the milliseconds of both are left out, so the captions are expected in order of time. """ if not self.captions: return 0 return ( self.captions[-1].end_in_seconds - self.captions[0].start_in_seconds ) @property def content(self) -> str: """ Return the webvtt captions as string. This property is useful in cases where the webvtt content is needed but no file-like destination is required. Storage in DB for instance. :raises ValueError: if the header text or metadata are not valid, or the text of a caption, style or comment could not be read back """ return vtt.to_str( self.captions, self.styles, self.header_comments, self.footer_comments, self.header_text, self.metadata, self.regions ) def _buffer_file(buffer: typing.Any) -> typing.Optional[str]: """ Return the path of the file of a file-like object, if it has one. A name that is not a path is not kept, such as the file descriptor of a file opened from one or a name in angle brackets like ```` that is not a file. :param buffer: the file-like object :returns: the path, or None """ name = getattr(buffer, 'name', None) if isinstance(name, (str, bytes, os.PathLike)): name = os.fsdecode(name) if (not isinstance(name, str) or not name or (name.startswith('<') and name.endswith('>') and not os.path.exists(name))): return None return name def _with_extension(file: str, extension: str) -> str: """ Return the path of a file with the extension of a format. :param file: the path of the file :param extension: the extension of the format, such as ``vtt`` :returns: the path as it is if its extension is one of the format, as ``.webvtt`` for ``vtt``, or else with the extension replaced """ name, current = os.path.splitext(file) if current.lower() in _FORMAT_EXTENSIONS[extension]: return file return f'{name}.{extension}' def _byte_order_mark( encoding: str, add_bom: typing.Optional[bool], has_bom: bool ) -> str: """ Return the Byte Order Mark to write before the content. :param encoding: the encoding, under any of its names :param add_bom: true to add a Byte Order Mark, false to leave it out, or None to keep the Byte Order Mark of the file that was read :param has_bom: true if the file that was read has a Byte Order Mark :returns: the Byte Order Mark as text, empty if there is none or the codec of the encoding writes it :raises LookupError: if the encoding is not known :raises ValueError: if add_bom is true and the encoding has no Byte Order Mark, or false and the codec of the encoding always writes one """ # equivalent names such as UTF-8 and utf8 become utf-8 name = codecs.lookup(encoding).name if name in utils._BOM_WRITING_CODECS: if add_bom is False: raise ValueError( f'The {encoding} encoding always writes a Byte Order Mark' ) return '' if name not in utils.CODEC_BOMS: if add_bom: raise ValueError(f'The {encoding} encoding has no Byte Order Mark') return '' if add_bom or (add_bom is None and has_bom): return utils.CODEC_BOMS[name].decode(name) return '' def _list_or_new(items: typing.Optional[typing.List[_T]]) -> typing.List[_T]: """ Return the list given, even if empty, or a new list for None. :param items: the list, kept so that changes to it show :returns: the list, or a new empty list """ return [] if items is None else items