diff --git a/.github/workflows/showcase-video.yml b/.github/workflows/showcase-video.yml new file mode 100644 index 0000000..f235277 --- /dev/null +++ b/.github/workflows/showcase-video.yml @@ -0,0 +1,127 @@ +name: Showcase video + +on: + workflow_dispatch: + push: + branches: [master, record-demo] + paths: + - 'src/**' + - 'examples/showcase/**' + - 'tools/record_showcase.py' + - 'tools/publish_showcase.py' + - 'tools/verify_showcase_video.py' + - 'meson.build' + - 'meson.options' + - '.github/workflows/showcase-video.yml' + +permissions: + contents: read + +concurrency: + group: showcase-video-${{ github.ref }} + cancel-in-progress: true + +jobs: + record: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install build and recording dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential meson ninja-build python3 \ + xvfb xauth ffmpeg libgl1-mesa-dri libgtk-4-dev libepoxy-dev \ + libpoppler-glib-dev libcairo2-dev libboost-program-options-dev \ + texlive-latex-recommended texlive-pictures + - name: Build application and showcase + run: | + meson setup build --buildtype=release -Dwerror=true + meson compile -C build + meson compile -C build doc + - name: Record compressed video + run: | + python3 tools/record_showcase.py build/src/pdfcube \ + build/examples/showcase/showcase.pdf build/showcase.mp4 --fps 30 --crf 28 + # Attachments must fit the free-plan limit. Keep fps/resolution intact. + if test "$(stat -c %s build/showcase.mp4)" -ge 9500000; then + ffmpeg -nostdin -v error -i build/showcase.mp4 -an -c:v libx264 \ + -preset slow -b:v 210k -pass 1 -passlogfile build/video-pass -f null /dev/null + ffmpeg -nostdin -v error -i build/showcase.mp4 -an -c:v libx264 \ + -preset slow -b:v 210k -pass 2 -passlogfile build/video-pass \ + -pix_fmt yuv420p -movflags +faststart build/showcase-small.mp4 + mv build/showcase-small.mp4 build/showcase.mp4 + fi + python3 tools/verify_showcase_video.py build/showcase.mp4 + printf '%s\n' "$GITHUB_SHA" > build/source-commit.txt + - uses: actions/upload-artifact@v4 + with: + name: showcase-video + path: | + build/showcase.mp4 + build/source-commit.txt + if-no-files-found: error + + publish: + needs: record + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Check upload credential + env: + MEDIA_TOKEN: ${{ secrets.DEMO_MEDIA_TOKEN }} + run: | + if test -z "$MEDIA_TOKEN"; then + echo '::error::Set DEMO_MEDIA_TOKEN to a user token with Contents and Issues write access to this repository. GITHUB_TOKEN cannot upload attachments.' + exit 1 + fi + - uses: actions/checkout@v4 + with: + ref: master + fetch-depth: 0 + - uses: actions/download-artifact@v4 + with: + name: showcase-video + path: video + - name: Require attachment-capable GitHub CLI + run: | + # Runner CLI packages can lag behind the attachment feature (gh 2.99+). + if ! gh issue edit --help | grep -q -- --attach; then + curl --fail --location --silent --show-error \ + https://github.com/cli/cli/releases/download/v2.99.0/gh_2.99.0_linux_amd64.deb \ + -o /tmp/gh_2.99.0_linux_amd64.deb + curl --fail --location --silent --show-error \ + https://github.com/cli/cli/releases/download/v2.99.0/gh_2.99.0_checksums.txt \ + -o /tmp/gh_2.99.0_checksums.txt + (cd /tmp && sha256sum --check --ignore-missing gh_2.99.0_checksums.txt) + sudo dpkg -i /tmp/gh_2.99.0_linux_amd64.deb + fi + gh issue edit --help | grep -q -- --attach + - name: Publish attachment and update README + env: + GH_TOKEN: ${{ secrets.DEMO_MEDIA_TOKEN }} + run: | + source_commit="$(cat video/source-commit.txt)" + test "$(git rev-parse HEAD)" = "$source_commit" || { + echo '::error::Source changed while recording; rerun against current master.' + exit 1 + } + python3 tools/publish_showcase.py video/showcase.mp4 \ + --repo "$GITHUB_REPOSITORY" --revision "$source_commit" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add README.md + git commit -m 'docs: refresh showcase video [skip ci]' + git fetch origin master + test "$(git rev-parse origin/master)" = "$source_commit" || { + echo '::error::Source changed during upload; refusing a stale README update.' + exit 1 + } + git rebase origin/master + git push origin HEAD:master diff --git a/README.md b/README.md index 1520ddb..2d3db7f 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,13 @@ application. It uses C++20, GTK 4 `GtkGLArea`, shader-based OpenGL/OpenGL ES 3, libepoxy, Poppler GLib, Cairo, Boost Program Options, and Meson. The obsolete GtkGLExt, fixed-function OpenGL, and Autotools implementation has been removed. + + +The showcase video is generated by the **Showcase video** workflow. Its first +successful publication will place the inline player here. + + + ## Requirements PDFCube requires GTK 4.12 or newer, Poppler GLib 22.0 or newer, and an diff --git a/examples/showcase/README.md b/examples/showcase/README.md index 5bcfbf6..a4e5008 100644 --- a/examples/showcase/README.md +++ b/examples/showcase/README.md @@ -55,6 +55,32 @@ request 1.25 seconds (`/Trans /D`). Change `\ShowcaseDwell` in the preamble to adjust dwell time. The final slide wraps to the cover with Replace. Press `1` to return to the first slide at any time, or `t` to toggle automatic playback. +## Record a web video + +On Linux, install FFmpeg (with libx264), Xvfb and xauth. +Build the application and `doc` target above, then run: + +```sh +python3 tools/record_showcase.py build/src/pdfcube \ + build/examples/showcase/showcase.pdf build/showcase-web.mp4 +``` + +The default is a silent 1280×720, **30 fps** H.264 MP4 with YUV420 pixels and +`faststart` metadata for browser playback. `--crf 23` controls compression; +raise it (for example `--crf 28`) for a smaller file, or lower it for sharper +text. You can also set `--width`, `--height`, `--fps`, and `--preset`. +Output files are never overwritten. + +The recorder uses PDFCube's actual OpenGL renderer on a private virtual +display. It advances by fixed frame steps rather than capturing the desktop, +so slow rendering cannot drop animation frames. It streams directly to FFmpeg +without storing raw frames. Every slide retains its PDF dwell time (five seconds +when unspecified); transition durations are rounded to the nearest frame. +It records one complete cycle, including the return to the cover. Transitions +unsupported by PDFCube keep the same instant fallback as normal playback. +The internal `--record-raw` CLI is intended for this script; use the script for +MP4 output. Recording ignores interactive demo mode and requires no `--program`. + ## Coverage - Box inward/outward: both select PDFCube's classic cube. @@ -77,3 +103,32 @@ names the next slide's effect. Keep these aligned if you reorder or add slides. The first and last pages use Replace; advancing from the final slide wraps to the first page. Artwork is generated from the fifth argument (a numeric seed), and the sixth argument chooses the background palette. + +## Automatic README publication + +`.github/workflows/showcase-video.yml` rebuilds the presentation and records a +30 fps MP4 when showcase or renderer sources change on `master`. Feature-branch +runs upload a downloadable Actions artifact; only `master` publishes the README. +The recording is verified before publication and kept below 10 MB. No video file +is committed to Git. + +Publication uses GitHub CLI 2.99 or newer to attach the video to a single +**Showcase video (CI media)** issue, then places the returned attachment URL in +the marked README section. That URL renders as an inline player. Each successful +run replaces the issue body and README URL. Older attachments are not deleted. +The README includes the recorded source commit; stale runs cannot overwrite a +newer source revision. README-only bot commits do not trigger recording or CI. + +One-time setup: create a repository Actions secret named `DEMO_MEDIA_TOKEN` +containing a fine-grained personal access token restricted to this repository, +with **Contents: read and write** and **Issues: read and write**. The token owner +must have push access. GitHub's attachment endpoint rejects the built-in Actions +installation token, so `GITHUB_TOKEN` cannot substitute for this secret. +The built-in token is used separately for the README commit. Repository rules +must permit that bot to update `master`; protected branches requiring pull +requests need a corresponding publication policy before enabling the workflow. +Do not paste a token into workflow YAML or commit it. + +Without the secret, recording and the artifact upload still run; publication +fails with setup instructions. Use **Actions → Showcase video → Run workflow** +on `master` after adding the secret. Fork pull requests never receive it. diff --git a/src/application.cc b/src/application.cc index 0bd76c5..6cabe4e 100644 --- a/src/application.cc +++ b/src/application.cc @@ -1,4 +1,5 @@ #include "application.hpp" +#include "recording.hpp" #include "navigation.hpp" #include "playback.hpp" #include "timing.hpp" @@ -62,7 +63,9 @@ void Application::activate_callback(GtkApplication* application, gpointer data) void Application::activate(GtkApplication* application) { window_ = gtk_application_window_new(application); gtk_window_set_title(GTK_WINDOW(window_), "PDFCube"); - gtk_window_set_default_size(GTK_WINDOW(window_), 960, 720); + gtk_window_set_default_size(GTK_WINDOW(window_), + options_.record_raw.empty() ? 960 : options_.record_width, + options_.record_raw.empty() ? 720 : options_.record_height); area_ = gtk_gl_area_new(); gtk_gl_area_set_allowed_apis( @@ -200,6 +203,12 @@ gboolean Application::render_callback(GtkGLArea* area, GdkGLContext* context, gp const int scale = gtk_widget_get_scale_factor(GTK_WIDGET(area)); const int width = gtk_widget_get_width(GTK_WIDGET(area)) * scale; const int height = gtk_widget_get_height(GTK_WIDGET(area)) * scale; + if (!self->options_.record_raw.empty() && !self->snapshot_written_) { + self->snapshot_written_ = true; + self->record_video(width, height); + g_application_quit(G_APPLICATION(self->application_)); + return TRUE; + } if (GdkFrameClock* clock = gtk_widget_get_frame_clock(GTK_WIDGET(area))) { self->frame_time_us_ = gdk_frame_clock_get_frame_time(clock); } @@ -240,6 +249,22 @@ gboolean Application::render_callback(GtkGLArea* area, GdkGLContext* context, gp return TRUE; } +void Application::record_video(int width, int height) { + if (width != options_.record_width || height != options_.record_height) + throw std::runtime_error("Recording size differs from requested size; use GDK_SCALE=1"); + std::ofstream output(options_.record_raw, std::ios::binary); + if (!output) throw std::runtime_error("Cannot open raw recording output"); + record_presentation(*document_, *renderer_, options_.record_fps, width, height, + [&](const std::vector& pixels, int repeats) { + for (int frame = 0; frame < repeats; ++frame) { + output.write(reinterpret_cast(pixels.data()), pixels.size()); + if (!output) throw std::runtime_error("Cannot write recording frame"); + } + }); + output.flush(); + if (!output) throw std::runtime_error("Cannot finish recording output"); +} + void Application::write_performance_log() const { if (options_.performance_log.empty()) return; std::ofstream output(options_.performance_log); diff --git a/src/application.hpp b/src/application.hpp index fc7898b..004160b 100644 --- a/src/application.hpp +++ b/src/application.hpp @@ -40,6 +40,7 @@ class Application { void reset_zoom(); void schedule_auto_advance(bool allow_immediate = false); void write_performance_log() const; + void record_video(int width, int height); FrameState frame_state() const; bool handle_key(guint keyval, GdkModifierType state); double animation_progress() const; diff --git a/src/instance.hpp b/src/instance.hpp index ba8c0b6..16031c3 100644 --- a/src/instance.hpp +++ b/src/instance.hpp @@ -5,6 +5,6 @@ namespace pdfcube { inline GApplicationFlags application_flags(const Options& options) { - return options.snapshot.empty() ? G_APPLICATION_DEFAULT_FLAGS : G_APPLICATION_NON_UNIQUE; + return options.snapshot.empty() && options.record_raw.empty() ? G_APPLICATION_DEFAULT_FLAGS : G_APPLICATION_NON_UNIQUE; } } diff --git a/src/options.cc b/src/options.cc index 797e99f..fc44202 100644 --- a/src/options.cc +++ b/src/options.cc @@ -75,6 +75,10 @@ ParseResult parse_options(int argc, char** argv) { po::options_description hidden("Hidden options"); hidden.add_options() ("input-file,i", po::value(), "PDF file") + ("record-raw", po::value(), "Export one presentation cycle as RGB24 frames") + ("record-fps", po::value(), "Export frame rate") + ("record-width", po::value(), "Export width") + ("record-height", po::value(), "Export height") ("snapshot", po::value(), "Write one PNG frame and exit") ("snapshot-progress", po::value(), "Transition progress used by --snapshot"); @@ -155,6 +159,23 @@ ParseResult parse_options(int argc, char** argv) { if (const auto* selected = value("input-file")) { result.options.input_file = selected->as(); } + if (const auto* selected = value("record-raw")) { + result.options.record_raw = selected->as(); + result.options.fullscreen = false; + result.options.demo = false; + if (result.options.input_file.empty() || !result.options.program.empty()) + throw std::invalid_argument("recording requires a PDF and no transition program"); + } + for (const auto& entry : std::vector>{ + {"record-fps", &result.options.record_fps}, + {"record-width", &result.options.record_width}, + {"record-height", &result.options.record_height}}) { + if (const auto* selected = value(entry.first)) *entry.second = selected->as(); + if (*entry.second < 1 || *entry.second > 8192) + throw std::invalid_argument("recording dimensions and rate must be in 1..8192"); + } + if (!result.options.record_raw.empty() && value("snapshot")) + throw std::invalid_argument("recording and snapshot are mutually exclusive"); if (const auto* selected = value("snapshot")) { result.options.snapshot = selected->as(); } diff --git a/src/options.hpp b/src/options.hpp index cfed3ae..ff0b7ff 100644 --- a/src/options.hpp +++ b/src/options.hpp @@ -21,6 +21,10 @@ struct Options { std::string program; std::string performance_log; std::string input_file; + std::string record_raw; + int record_fps{30}; + int record_width{1280}; + int record_height{720}; std::string snapshot; double snapshot_progress{0.0}; bool snapshot_transition{false}; diff --git a/src/recording.hpp b/src/recording.hpp new file mode 100644 index 0000000..b5f8ecd --- /dev/null +++ b/src/recording.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include "playback.hpp" + +#include +#include + +namespace pdfcube { + +// Fixed frame steps: no GUI clock, timers, or real-time pauses. The same +// sequence is used by the application and the shared headless renderer tests. +template +void record_presentation(Document& document, Renderer& renderer, int fps, + int width, int height, Emit emit) { + FrameState state; + auto render = [&](int repeats) { + renderer.render(width, height, state); + emit(renderer.read_rgb(width, height), repeats); + }; + auto dwell = [&] { + const auto& page = document.page_info(state.current_page); + const double seconds = page.duration_ms > 0 ? page.duration_ms / 1000.0 : 5.0; + render(std::max(1, static_cast(std::round(seconds * fps)))); + }; + dwell(); + for (int page = 1; page <= document.page_count(); ++page) { + state.next_page = page % document.page_count(); + const auto& info = document.page_info(state.next_page); + state.transition = transition_from_page(info); + apply_transition_metadata(state, info); + if (state.transition != Transition::switch_page && info.transition_seconds > 0) { + const int frames = std::max(1, static_cast(std::round(info.transition_seconds * fps))); + for (int frame = 0; frame < frames; ++frame) { + state.progress = frame / static_cast(frames); + render(1); + } + } + state.current_page = state.next_page; + state.transition = Transition::none; + if (page < document.page_count()) dwell(); + } + render(1); +} + +} // namespace pdfcube diff --git a/src/renderer.cc b/src/renderer.cc index 31a6e75..dbd3e50 100644 --- a/src/renderer.cc +++ b/src/renderer.cc @@ -733,6 +733,25 @@ void Renderer::render(int width, int height, const FrameState& frame) { draw_color(top_, other_side.value.data()); } +std::vector Renderer::read_rgb(int width, int height) const { + const std::size_t stride = static_cast(width) * 3; + std::vector pixels(static_cast(width) * height * 4); + std::vector flipped(stride * height); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + // RGBA readback works on both desktop OpenGL and GLES (RGB is not + // universally supported by GLES framebuffer implementations). + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); + if (glGetError() != GL_NO_ERROR) + throw std::runtime_error("Recording framebuffer readback failed"); + for (int row = 0; row < height; ++row) { + const auto* source = pixels.data() + static_cast(height - row - 1) * width * 4; + auto* destination = flipped.data() + row * stride; + for (int column = 0; column < width; ++column) + std::copy_n(source + column * 4, 3, destination + column * 3); + } + return flipped; +} + void Renderer::write_png(const std::string& filename, int width, int height) const { std::vector pixels(static_cast(width * height * 4)); glPixelStorei(GL_PACK_ALIGNMENT, 1); diff --git a/src/renderer.hpp b/src/renderer.hpp index ab04cc2..6d61be5 100644 --- a/src/renderer.hpp +++ b/src/renderer.hpp @@ -54,6 +54,7 @@ class Renderer { void initialize(); void release(); void render(int width, int height, const FrameState& frame); + std::vector read_rgb(int width, int height) const; void write_png(const std::string& filename, int width, int height) const; [[nodiscard]] bool idle_preparation_pending() const; diff --git a/tests/README.md b/tests/README.md index 5114413..6c6fb4b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -54,3 +54,10 @@ gallery** workflow. Select the branch under Actions → Run workflow, then downl the commit-labelled `transition-review` artifact for visual inspection. It is not a required pre-submit check. Local generation remains available through `tools/transition_gallery.py`. + +The web-recording integration case is an exceptional sub-second test: one shared +surfaceless context renders a fixed-step cycle, followed by real H.264 encoding +and decoding. It retains dwell/transition frame counts, endpoint, orientation, +codec, dimensions, faststart, and parser-guard checks. On the local runner it +takes about 0.6 seconds; report its 500 ms SLO miss rather than dropping checks. +Publication/README replacement tests mock remote calls and remain below 500 ms. diff --git a/tests/meson.build b/tests/meson.build index 86af0a4..47943ee 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -117,3 +117,16 @@ if host_machine.system() == 'linux' timeout: test_watchdog, is_parallel: false, suite: ['render'], env: render_environment) endif +ffmpeg = find_program('ffmpeg', required: false) +ffprobe = find_program('ffprobe', required: false) +if host_machine.system() == 'linux' and ffmpeg.found() and ffprobe.found() + test('web recording', python, + args: [files('test_recording.py'), render_driver, + files('../tools/record_showcase.py')], + timeout: test_watchdog, is_parallel: false, env: render_environment, + ) +endif + +test('showcase publication', python, + args: [files('test_publish_showcase.py'), files('../tools/publish_showcase.py')], +) diff --git a/tests/render_driver.cc b/tests/render_driver.cc index c27bfa2..1a7689b 100644 --- a/tests/render_driver.cc +++ b/tests/render_driver.cc @@ -1,4 +1,5 @@ #include "options.hpp" +#include "recording.hpp" #include "playback.hpp" #include "renderer.hpp" #include "performance.hpp" @@ -106,7 +107,16 @@ int main() { if (frame.transition == pdfcube::Transition::switch_page && frame.progress <= 0) frame.transition = pdfcube::Transition::none; } - if (sequence) { + if (!options.record_raw.empty()) { + std::ofstream output(options.record_raw, std::ios::binary); + pdfcube::record_presentation(pages->document, pages->renderer, options.record_fps, + options.record_width, options.record_height, + [&](const std::vector& pixels, int repeats) { + for (int i=0; i(pixels.data()),pixels.size()); + }); + if (!output) throw std::runtime_error("recording output failed"); + } else if (sequence) { // Render every frame of a 60 Hz second directly, without PNG encoding // or advancing a wall clock. Readback catches deferred GL failures. std::vector pixels(320*240*4); diff --git a/tests/test_options.cc b/tests/test_options.cc index 8064288..9e6f6f3 100644 --- a/tests/test_options.cc +++ b/tests/test_options.cc @@ -66,6 +66,14 @@ int main() { assert(invalid.should_exit && invalid.exit_code == 2); assert(invalid.output.find("Command line option parsing error") != std::string::npos); } + for (const auto& invalid : {parse({"--record-raw","out.rgb"}), + parse({"--record-raw","out.rgb","--snapshot","out.png","deck.pdf"}), + parse({"--record-fps","0"}),parse({"--record-width","-1"})}) { + assert(invalid.should_exit && invalid.exit_code == 2); + } + const auto recording = parse({"--record-raw","out.rgb","--demo","deck.pdf"}); + assert(!recording.should_exit && !recording.options.demo && !recording.options.fullscreen); + assert(recording.options.input_file == "deck.pdf" && recording.options.record_fps == 30); std::ofstream(root/".pdfcuberc") << "bgcolor=0.1:0.2:0.3\nno-fullscreen=true\nexperimental=true\n"; const auto configured = parse({"deck.pdf"}); assert(!configured.should_exit && configured.options.experimental && !configured.options.fullscreen); diff --git a/tests/test_publish_showcase.py b/tests/test_publish_showcase.py new file mode 100644 index 0000000..806263a --- /dev/null +++ b/tests/test_publish_showcase.py @@ -0,0 +1,72 @@ +import importlib.util +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch +import json + +spec = importlib.util.spec_from_file_location('publish_showcase', sys.argv.pop(1)) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +URL = 'https://github.com/user-attachments/assets/1234-abcd' +SHA = 'a' * 40 +TEXT = '# PDFCube\n\n' + module.START + '\nPending.\n' + module.END + '\nRest.\n' + + +class PublicationTests(unittest.TestCase): + def test_replace_and_idempotence(self): + result = module.updated_readme(TEXT, URL, SHA) + self.assertTrue(result.startswith('# PDFCube\n\n')) + self.assertTrue(result.endswith('\nRest.\n')) + self.assertIn('\n\n' + URL + '\n\n', result) + self.assertEqual(module.updated_readme(result, URL, SHA), result) + + def test_reject_malformed_input(self): + for text, url, sha in ((TEXT, 'https://example.com/demo.mp4', SHA), + (TEXT, URL, 'bad'), ('no markers', URL, SHA), + (TEXT + module.START, URL, SHA), + (module.END + module.START, URL, SHA)): + with self.assertRaises(ValueError): + module.updated_readme(text, url, sha) + + def test_reuses_issue_and_updates_from_returned_attachment(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + video, readme = root / 'demo.mp4', root / 'README.md' + video.write_bytes(b'example video') + readme.write_text(TEXT) + argv = ['publish', str(video), '--repo', 'baol/pdfcube', '--revision', SHA, + '--readme', str(readme)] + calls = [] + def gh(*args): + calls.append(args) + if args[:2] == ('issue', 'list'): + return json.dumps([{'title': module.TITLE, 'number': 7}]) + if args[:2] == ('issue', 'edit'): + self.assertIn('--attach', args) + body = Path(args[args.index('--body-file') + 1]).read_text() + self.assertIn(f'![]({video.resolve()})', body) + return '' + return json.dumps({'body': URL}) + with patch.object(sys, 'argv', argv), patch.object(module, 'gh', gh): + module.main() + self.assertEqual(readme.read_text(), module.updated_readme(TEXT, URL, SHA)) + self.assertEqual([c[1] for c in calls], ['list', 'edit', 'view']) + + def test_missing_upload_url_leaves_readme_unchanged(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + video, readme = root / 'demo.mp4', root / 'README.md' + video.write_bytes(b'video') + readme.write_text(TEXT) + with patch.object(sys, 'argv', ['publish', str(video), '--repo', 'baol/pdfcube', + '--revision', SHA, '--readme', str(readme)]), \ + patch.object(module, 'gh', side_effect=['[]', 'https://github.com/baol/pdfcube/issues/7', + '{"body":"missing"}']): + with self.assertRaises(RuntimeError): + module.main() + self.assertEqual(readme.read_text(), TEXT) + + +unittest.main() diff --git a/tests/test_recording.py b/tests/test_recording.py new file mode 100644 index 0000000..c47eb7e --- /dev/null +++ b/tests/test_recording.py @@ -0,0 +1,82 @@ +"""Check fixed-step timing, complete cycle, pixel orientation and web encoding.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +from transition_fixture import make_pdf + +from render_client import Frames +from PIL import Image, ImageChops, ImageStat +from unittest.mock import patch +import importlib.util + +driver, recorder = map(lambda s: Path(s).resolve(), sys.argv[1:]) +with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + pdf, output = root / "fixture.pdf", root / "video.mp4" + make_pdf(pdf) + frames = Frames(driver,root) + raw = root / "frames.rgb" + frames.render("--record-raw",raw,"--record-width","320","--record-height","180", + "--record-fps","4",pdf) + subprocess.run(["ffmpeg","-v","error","-f","rawvideo","-pixel_format","rgb24", + "-video_size","320x180","-framerate","4","-i",str(raw), + "-c:v","libx264","-threads","1","-preset","fast","-crf","23", + "-pix_fmt","yuv420p","-movflags","+faststart",str(output)], check=True) + data = json.loads(subprocess.check_output([ + "ffprobe", "-v", "error", "-show_streams", "-of", "json", str(output)])) + stream = data["streams"][0] + assert len(data["streams"]) == 1 + assert (stream["width"], stream["height"]) == (320, 180) + assert stream["codec_name"] == "h264" + assert stream["pix_fmt"] == "yuv420p" + assert stream["r_frame_rate"] == "4/1" + # First slide fallback dwell=5s, animation=1.25s, second dwell=4s, + # instant wrap to first plus one final frame. + assert int(stream["nb_frames"]) == 42, stream + mp4 = output.read_bytes() + assert mp4.index(b"moov") < mp4.index(b"mdat"), "faststart metadata missing" + pixels = subprocess.check_output([ + "ffmpeg", "-v", "error", "-threads", "1", "-i", str(output), "-f", "rawvideo", + "-pix_fmt", "rgb24", "-threads", "1", "pipe:1"]) + size = 320 * 180 * 3 + first, incoming, last = pixels[:size], pixels[30*size:31*size], pixels[-size:] + def difference(a,b): + left = Image.frombytes("RGB",(320,180),a) + right = Image.frombytes("RGB",(320,180),b) + return sum(ImageStat.Stat(ImageChops.difference(left,right)).mean)/3 + assert difference(first,last) < 5 + assert difference(first,incoming) > 30 + # Compare against an independent PNG snapshot + # to catch framebuffer readback being vertically flipped. + snapshot = root / "cover.png" + frames.render("--snapshot",snapshot,pdf) + with Image.open(snapshot) as image: + reference = image.convert("RGB").crop((0,30,320,210)).tobytes() + frames.close() + error = difference(first,reference) + # Low-resolution grid lines snap to whole pixels; the reference is downsampled. + assert error < 20, f"snapshot mismatch: {error}" + flipped = b"".join(first[y*960:(y+1)*960] for y in reversed(range(180))) + assert difference(first,reference) < difference(flipped,reference) + # Pixel content checks above also catch empty/black renders. + spec = importlib.util.spec_from_file_location("recorder",recorder) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + # Parser guards execute in-process, without starting a GUI or encoder. + command = ["recorder",str(driver),str(pdf),str(output),"--width","320","--height","180"] + with patch.object(sys,"argv",command), patch("sys.stderr"): + try: + module.main() + raise AssertionError("existing output was overwritten") + except SystemExit as error: + assert error.code != 0 + with patch.object(sys,"argv",command + ["--width","321"]), patch("sys.stderr"): + try: + module.main() + raise AssertionError("odd dimension was accepted") + except SystemExit as error: + assert error.code != 0 diff --git a/tools/publish_showcase.py b/tools/publish_showcase.py new file mode 100644 index 0000000..53cfd8d --- /dev/null +++ b/tools/publish_showcase.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Upload a showcase attachment and replace only its marked README section.""" +import argparse +import json +from pathlib import Path +import re +import subprocess +import tempfile + +START = "" +END = "" +ATTACHMENT = re.compile(r"https://github\.com/user-attachments/assets/[A-Za-z0-9-]+(?:\.mp4)?") +TITLE = "Showcase video (CI media)" + + +def updated_readme(text, url, revision): + if not ATTACHMENT.fullmatch(url): + raise ValueError("expected a GitHub user-attachment URL") + if not re.fullmatch(r"[0-9a-f]{40}", revision): + raise ValueError("expected a full source commit SHA") + if text.count(START) != 1 or text.count(END) != 1: + raise ValueError("README must contain exactly one showcase marker pair") + before, rest = text.split(START) + _, after = rest.split(END) + if END in before: + raise ValueError("showcase markers are reversed") + return (before + START + "\n\n" + url + "\n\n" + + f"*Autogenerated showcase · 30 fps · source `{revision}`.*\n\n" + END + after) + + +def gh(*args): + return subprocess.check_output(["gh", *args], text=True).strip() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("video", type=Path) + parser.add_argument("--repo", required=True) + parser.add_argument("--revision", required=True) + parser.add_argument("--readme", type=Path, default=Path("README.md")) + args = parser.parse_args() + text = args.readme.read_text() + # Validate the local document before creating any remote content. + updated_readme(text, "https://github.com/user-attachments/assets/validation", args.revision) + if not args.video.is_file() or not 0 < args.video.stat().st_size < 10_000_000: + parser.error("video must exist and be below GitHub's 10 MB attachment limit") + issues = json.loads(gh("issue", "list", "--repo", args.repo, "--state", "all", + "--search", f'"{TITLE}" in:title', "--json", "number,title")) + matches = [issue for issue in issues if issue["title"] == TITLE] + if len(matches) > 1: + raise RuntimeError("multiple CI media issues found; keep one canonical issue") + # GitHub CLI rewrites this standalone image reference into an inline video URL. + with tempfile.TemporaryDirectory() as directory: + body = Path(directory) / "body.md" + body.write_text(f"Generated from `{args.revision}`. Updated automatically by CI.\n\n" + f"![]({args.video.resolve()})\n") + if matches: + issue = str(matches[0]["number"]) + gh("issue", "edit", issue, "--repo", args.repo, "--body-file", str(body), + "--attach", str(args.video.resolve())) + else: + issue = gh("issue", "create", "--repo", args.repo, "--title", TITLE, + "--body-file", str(body), "--attach", str(args.video.resolve())) + posted = json.loads(gh("issue", "view", issue, "--repo", args.repo, "--json", "body")) + urls = ATTACHMENT.findall(posted["body"]) + if len(urls) != 1: + raise RuntimeError("upload did not produce exactly one video attachment URL") + args.readme.write_text(updated_readme(text, urls[0], args.revision)) + print(urls[0]) + + +if __name__ == "__main__": + main() diff --git a/tools/record_showcase.py b/tools/record_showcase.py new file mode 100644 index 0000000..4b64a0d --- /dev/null +++ b/tools/record_showcase.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Render a PDFCube presentation at fixed frame steps and encode a web MP4.""" +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("binary", type=Path) + parser.add_argument("pdf", type=Path) + parser.add_argument("output", type=Path, help="Output .mp4 (must not exist)") + parser.add_argument("--fps", type=int, default=30) + parser.add_argument("--width", type=int, default=1280) + parser.add_argument("--height", type=int, default=720) + parser.add_argument("--crf", type=int, default=23, + help="H.264 quality: lower is larger/sharper (default: 23)") + parser.add_argument("--threads", type=int, default=2, help="FFmpeg encoder threads") + parser.add_argument("--preset", choices=("fast", "medium", "slow"), default="slow") + parser.add_argument("--inside-xvfb", action="store_true", help=argparse.SUPPRESS) + args = parser.parse_args() + if not 1 <= args.fps <= 120: + parser.error("--fps must be in 1..120") + if any(n < 2 or n > 8192 or n % 2 for n in (args.width, args.height)): + parser.error("dimensions must be even numbers in 2..8192") + if not 1 <= args.threads <= 64: + parser.error("--threads must be in 1..64") + if not 0 <= args.crf <= 51: + parser.error("--crf must be in 0..51") + if args.output.suffix.lower() != ".mp4": + parser.error("output must end in .mp4") + if args.output.exists(): + parser.error("output already exists; choose another path") + for path in (args.binary, args.pdf): + if not path.is_file(): + parser.error(f"missing input: {path}") + for name in ("ffmpeg", "xvfb-run"): + if not shutil.which(name): + parser.error(f"{name} is required") + if not args.inside_xvfb: + return subprocess.call([ + "xvfb-run", "-a", f"--server-args=-screen 0 {args.width}x{args.height}x24 -noreset", + sys.executable, str(Path(__file__).resolve()), + *sys.argv[1:], "--inside-xvfb"]) + + args.output.parent.mkdir(parents=True, exist_ok=True) + env = dict(os.environ, GDK_BACKEND="x11", GDK_SCALE="1", GDK_DPI_SCALE="1", + LIBGL_ALWAYS_SOFTWARE="1", GTK_A11Y="none", + DBUS_SESSION_BUS_ADDRESS="disabled:", GIO_USE_VFS="local") + # Encode into the destination filesystem, publishing only after both programs + # succeed. No multi-gigabyte raw recording or PNG sequence is kept on disk. + with tempfile.TemporaryDirectory(prefix=".pdfcube-record-", dir=args.output.parent) as tmp: + temporary = Path(tmp) / "video.mp4" + encoder = subprocess.Popen([ + "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning", "-n", + "-f", "rawvideo", "-pixel_format", "rgb24", "-video_size", + f"{args.width}x{args.height}", "-framerate", str(args.fps), "-i", "pipe:0", + "-an", "-c:v", "libx264", "-threads", str(args.threads), "-preset", args.preset, "-crf", str(args.crf), + "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(temporary)], + stdin=subprocess.PIPE) + try: + capture = subprocess.run([ + str(args.binary.resolve()), "--record-raw", "/dev/stdout", + "--record-fps", str(args.fps), "--record-width", str(args.width), + "--record-height", str(args.height), str(args.pdf.resolve())], + env=env, stdout=encoder.stdin) + encoder.stdin.close() + encoded = encoder.wait() + if capture.returncode or encoded: + raise RuntimeError(f"recording failed (renderer={capture.returncode}, encoder={encoded})") + # Hard-link creation refuses to replace an output created meanwhile. + os.link(temporary, args.output) + finally: + if encoder.stdin and not encoder.stdin.closed: + encoder.stdin.close() + if encoder.poll() is None: + encoder.terminate() + encoder.wait() + print(f"Saved {args.output} ({args.output.stat().st_size / 1024 / 1024:.1f} MiB)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/verify_showcase_video.py b/tools/verify_showcase_video.py new file mode 100644 index 0000000..474221d --- /dev/null +++ b/tools/verify_showcase_video.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Validate a generated showcase before publishing its attachment.""" +import json +from pathlib import Path +import subprocess +import sys + +video = Path(sys.argv[1]) +assert 0 < video.stat().st_size < 10_000_000, "video exceeds attachment size limit" +data = json.loads(subprocess.check_output([ + "ffprobe", "-v", "error", "-show_streams", "-of", "json", str(video)])) +assert len(data["streams"]) == 1, "expected a single silent video stream" +stream = data["streams"][0] +assert stream["codec_name"] == "h264" +assert stream["pix_fmt"] == "yuv420p" +assert (stream["width"], stream["height"]) == (1280, 720) +assert stream["avg_frame_rate"] == "30/1" +assert float(stream["duration"]) > 0 +content = video.read_bytes() +assert content.index(b"moov") < content.index(b"mdat"), "faststart missing" +# Decode samples from across the video and reject an empty framebuffer capture. +for fraction in (0, .25, .5, .75, .99): + pixels = subprocess.check_output([ + "ffmpeg", "-nostdin", "-v", "error", "-ss", + str(float(stream["duration"]) * fraction), "-i", str(video), + "-frames:v", "1", "-vf", "scale=64:36", "-f", "rawvideo", + "-pix_fmt", "rgb24", "pipe:1"]) + assert len(pixels) == 64 * 36 * 3 and max(pixels) - min(pixels) > 30, "blank frame" +print(f"Verified {video}: 720p, 30 fps, {video.stat().st_size} bytes")