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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions .github/workflows/showcase-video.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- showcase-video:start -->

The showcase video is generated by the **Showcase video** workflow. Its first
successful publication will place the inline player here.

<!-- showcase-video:end -->

## Requirements

PDFCube requires GTK 4.12 or newer, Poppler GLib 22.0 or newer, and an
Expand Down
55 changes: 55 additions & 0 deletions examples/showcase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
27 changes: 26 additions & 1 deletion src/application.cc
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include "application.hpp"
#include "recording.hpp"
#include "navigation.hpp"
#include "playback.hpp"
#include "timing.hpp"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<unsigned char>& pixels, int repeats) {
for (int frame = 0; frame < repeats; ++frame) {
output.write(reinterpret_cast<const char*>(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);
Expand Down
1 change: 1 addition & 0 deletions src/application.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/instance.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
21 changes: 21 additions & 0 deletions src/options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string>(), "PDF file")
("record-raw", po::value<std::string>(), "Export one presentation cycle as RGB24 frames")
("record-fps", po::value<int>(), "Export frame rate")
("record-width", po::value<int>(), "Export width")
("record-height", po::value<int>(), "Export height")
("snapshot", po::value<std::string>(), "Write one PNG frame and exit")
("snapshot-progress", po::value<double>(),
"Transition progress used by --snapshot");
Expand Down Expand Up @@ -155,6 +159,23 @@ ParseResult parse_options(int argc, char** argv) {
if (const auto* selected = value("input-file")) {
result.options.input_file = selected->as<std::string>();
}
if (const auto* selected = value("record-raw")) {
result.options.record_raw = selected->as<std::string>();
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<std::pair<const char*, int*>>{
{"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<int>();
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<std::string>();
}
Expand Down
4 changes: 4 additions & 0 deletions src/options.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
45 changes: 45 additions & 0 deletions src/recording.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#pragma once

#include "playback.hpp"

#include <cmath>
#include <functional>

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 <class Emit>
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<int>(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<int>(std::round(info.transition_seconds * fps)));
for (int frame = 0; frame < frames; ++frame) {
state.progress = frame / static_cast<double>(frames);
render(1);
}
}
state.current_page = state.next_page;
state.transition = Transition::none;
if (page < document.page_count()) dwell();
}
render(1);
}

} // namespace pdfcube
19 changes: 19 additions & 0 deletions src/renderer.cc
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,25 @@ void Renderer::render(int width, int height, const FrameState& frame) {
draw_color(top_, other_side.value.data());
}

std::vector<unsigned char> Renderer::read_rgb(int width, int height) const {
const std::size_t stride = static_cast<std::size_t>(width) * 3;
std::vector<unsigned char> pixels(static_cast<std::size_t>(width) * height * 4);
std::vector<unsigned char> 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<std::size_t>(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<unsigned char> pixels(static_cast<std::size_t>(width * height * 4));
glPixelStorei(GL_PACK_ALIGNMENT, 1);
Expand Down
Loading
Loading