diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a3d660..71250e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,13 +23,22 @@ jobs: contents: write steps: - uses: actions/checkout@v4 + # The User Guide PDF and the screenshots are NOT built in CI: neither + # pandoc/TeX Live nor Nuke ship on the GitHub-hosted runner, and installing + # the TeX Live toolchain on every release is slow and heavy. Both are built + # locally and committed instead (`make docs`); CI only bundles the + # committed docs/user-guide.pdf and docs/images/*.png into the release zip. - name: Build release zip run: | - mkdir -p staging/Labelmaker + mkdir -p staging/Labelmaker/docs/images cp __init__.py labelmaker.py labelmaker_config.py labelmaker_prefs.py \ labelmaker_deoverlap.py labelmaker_prefs_dialog.py \ labelmaker_config_editor.py menu.py base_config.json README.md \ staging/Labelmaker/ + # Bundle the committed User Guide PDF and the screenshots the README + # references, so its relative image links resolve. + cp docs/user-guide.pdf staging/Labelmaker/docs/ + cp docs/images/*.png staging/Labelmaker/docs/images/ touch "staging/Labelmaker/${{ github.ref_name }}" cd staging zip -r "../Labelmaker-${{ github.ref_name }}.zip" Labelmaker/ diff --git a/.gitignore b/.gitignore index b6e4761..09e2608 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,13 @@ dmypy.json # Pyre type checker .pyre/ +.tools/ + +# Nuke autosave files +*.nk.autosave + +# Throwaway HOME for doc capture sessions +.capture-home/ + +# Intermediate Markdown generated from the README for the User Guide PDF +docs/.build/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3968315 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ +# Labelmaker — project instructions + +Labelmaker is a Nuke plugin that replaces Nuke's autolabel system with rich, +multi-line node labels, plus an Edit-menu Preferences dialog, a Config Editor, and +a De-overlap command. + +## Documentation must stay in sync with the code + +**Whenever a change affects user-facing behaviour, update the documentation and +regenerate its screenshots in the same change.** User-facing means any of: + +- a feature or the per-node-class labels (`labelmaker.py`, `base_config.json`) +- the Edit-menu commands (`menu.py`) +- the Preferences fields (`labelmaker_prefs.py`, `labelmaker_prefs_dialog.py`) +- the Config Editor UI (`labelmaker_config_editor.py`) +- the config JSON format or the config cascade (`labelmaker_config.py`) + +The documentation has a single source of truth, `README.md`: + +- `README.md` — the complete reference (install, features, configuration, + preferences); references `docs/images/*.png` +- `docs/user-guide.pdf` — the User Guide, built from `README.md` by `make pdf` + (pandoc + xelatex). `docs/pandoc/build_user_guide_md.py` derives the Markdown by + dropping the install section and adding a title block; it is styled with the + project LaTeX class `docs/latex/training_doc.cls` (+ `docs/latex/logo.pdf`) and + `docs/pandoc/pdf.yaml`. The PDF is **built locally and committed** — the + GitHub runner has neither pandoc/TeX Live nor Nuke, so nothing in `docs/` is + regenerated in CI. Rebuild it with `make pdf` and commit the result whenever the + README changes. +- `docs/images/*.png` — screenshots, generated (committed to the repo) + +Keep the README free of pandoc-specific image attributes (`{ width=… }`): it +renders on GitHub as GFM, which shows those as literal text. PDF image layout and +sizing is handled by `docs/pandoc/float-images.lua` (a pandoc filter that anchors +each screenshot beside its paragraph in a two-column minipage row — text left, +image pinned right at half width — and centres the hero image) plus +`docs/pandoc/pdf.yaml`, not per-image. + +### Source → documentation map + +| If you change … | Update … | Regenerate … | +|---|---|---| +| `base_config.json`, `labelmaker.py` (label content) | feature sections of `README.md` | DAG screenshots — `docs/screenshots/features.nk` (run `make screenshots-dag`) | +| `menu.py` (menu commands) | menu references in `README.md` | panel screenshots (`make screenshots-panels`) | +| `labelmaker_prefs*.py` (preferences) | Preferences table/section in `README.md` | `prefs_dialog.png` (`make screenshots-panels`) | +| `labelmaker_config_editor.py` | Config Editor section in `README.md` | `config_editor.png` (`make screenshots-panels`); keep `setObjectName` targets in sync with `docs/screenshots/panels.scenarios.json` | +| config format/cascade | Configuration section in `README.md` | — | + +### How to regenerate + +Screenshots come from [nuke-screenshotter](https://github.com/charlesangus/nuke-screenshotter) +(`pip install` it once), driving a real Nuke under `xvfb-run`: + +```sh +make screenshots # regenerate docs/images/*.png (needs Nuke; commit the PNGs) +make pdf # rebuild docs/user-guide.pdf (needs pandoc + xelatex; commit the PDF) +make docs # both +``` + +- DAG/autolabel shots come from `docs/screenshots/features.nk` (regenerate that + scene with `nuke -t docs/screenshots/build_features_nk.py`). +- Prefs/Config-Editor window shots come from `docs/screenshots/panels.scenarios.json`. +- Both runs load Labelmaker into the capture session via + `docs/screenshots/bootstrap/menu.py` (put on `NUKE_PATH` by the Makefile). + Getting the autolabels (not the bare class names) to render used to require a + cut/paste redraw hack in that bootstrap; nuke-screenshotter v1.2 (#6) does + the required label warm-up in its capture path, so the hack has been removed. + Screenshot regeneration therefore needs the **v1.2-or-later** screenshotter + (`pip install --upgrade "git+https://github.com/charlesangus/nuke-screenshotter"`). + +CI (`.github/workflows/release.yml`) does **not** build any docs — the GitHub +runner has neither Nuke (for screenshots) nor pandoc/TeX Live (for the PDF), and +installing the TeX Live toolchain on every release is slow and heavy. It only +bundles the committed `docs/user-guide.pdf` and `docs/images/*.png` into the +release zip, so **both the PDF and the PNGs must be committed**. Regenerate them +locally with `make docs` and commit before tagging a release. + +## Conventions + +- Run `ruff check .` and `pytest tests/` before committing (see `pyproject.toml`). +- PySide6 imports in `labelmaker*.py` are deferred (not at module level) to avoid + import-order issues in Nuke; keep them that way. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a71f741 --- /dev/null +++ b/Makefile @@ -0,0 +1,76 @@ +# Makefile — regenerate Labelmaker's documentation. +# +# make screenshots regenerate all PNGs under docs/images/ (requires Nuke + +# nuke-screenshotter + xvfb-run on headless Linux) +# make pdf build docs/user-guide.pdf from the README (requires +# pandoc + xelatex only — no Nuke). CI does NOT build the +# docs; run this locally and commit the PDF (see CLAUDE.md). +# make docs screenshots + pdf (full local regen) +# make clean remove generated PDFs +# +# The screenshots come from nuke-screenshotter: +# pip install "git+https://github.com/charlesangus/nuke-screenshotter" +# Override the Nuke path or screenshotter command if they are not on PATH: +# make screenshots NUKE=/path/to/nuke SHOTTER=/path/to/nuke-screenshotter + +NUKE ?= nuke +SHOTTER ?= nuke-screenshotter +ZOOM ?= 2.0 + +DOCS := docs +IMAGES := $(DOCS)/images +BOOTSTRAP := $(DOCS)/screenshots/bootstrap +FEATURES := $(DOCS)/screenshots/features.nk +SCENARIOS := $(DOCS)/screenshots/panels.scenarios.json +PDF_YAML := $(DOCS)/pandoc/pdf.yaml +FLOAT_FILTER := $(DOCS)/pandoc/float-images.lua + +# The User Guide PDF is derived from the README (single source of truth) by +# build_user_guide_md.py, which drops the install section and adds a title block. +BUILD_DIR := $(DOCS)/.build +GUIDE_MD := $(BUILD_DIR)/user-guide.md +GUIDE_SCRIPT := $(DOCS)/pandoc/build_user_guide_md.py + +PDFS := $(DOCS)/user-guide.pdf + +# Run the capture session in a CLEAN, reproducible Nuke environment: +# - NUKE_PATH is ONLY our bootstrap (no inherited NUKE_PATH), so the capture +# loads Labelmaker and nothing else. +# - HOME points at a throwaway dir so the user's personal ~/.nuke plugins (and +# their callbacks, which would otherwise fire during the capture) are not +# sourced. Nuke writes a fresh, empty ~/.nuke there. +# This keeps the docs identical no matter whose machine regenerates them. +export NUKE_PATH := $(CURDIR)/$(BOOTSTRAP) +CAPTURE_HOME := $(CURDIR)/.capture-home + +.PHONY: docs screenshots screenshots-dag screenshots-panels pdf clean help + +docs: screenshots pdf ## Regenerate screenshots and PDFs (needs Nuke) + +screenshots: screenshots-dag screenshots-panels ## Regenerate every PNG (needs Nuke) + +screenshots-dag: ## Capture the DAG/autolabel screenshots from features.nk + mkdir -p "$(CAPTURE_HOME)/.nuke" + HOME="$(CAPTURE_HOME)" $(SHOTTER) $(FEATURES) $(IMAGES) --zoom $(ZOOM) --nuke-exec $(NUKE) + +screenshots-panels: ## Capture the Preferences / Config Editor windows + mkdir -p "$(CAPTURE_HOME)/.nuke" + HOME="$(CAPTURE_HOME)" $(SHOTTER) --scenarios $(SCENARIOS) --output-dir $(IMAGES) --nuke-exec $(NUKE) + +pdf: $(PDFS) ## Build the User Guide PDF from the README (needs pandoc + xelatex) + +# Build docs/user-guide.pdf from README.md. The README's image paths are +# repo-root-relative (docs/images/...), so the resource path starts at $(CURDIR). +$(DOCS)/user-guide.pdf: README.md $(GUIDE_SCRIPT) $(PDF_YAML) $(FLOAT_FILTER) $(DOCS)/latex/training_doc.cls + mkdir -p $(BUILD_DIR) + python3 $(GUIDE_SCRIPT) README.md $(GUIDE_MD) + TEXINPUTS="$(CURDIR)/$(DOCS)/latex:$$TEXINPUTS" \ + pandoc --defaults $(PDF_YAML) --lua-filter $(FLOAT_FILTER) --resource-path "$(CURDIR):$(DOCS):$(DOCS)/latex" -o $@ $(GUIDE_MD) + +clean: ## Remove generated PDFs and the intermediate build dir + rm -f $(PDFS) + rm -rf $(BUILD_DIR) + +help: ## List targets + @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \ + | awk 'BEGIN{FS=":.*?## "}{printf " %-20s %s\n", $$1, $$2}' diff --git a/README.md b/README.md index c5ed232..e22db67 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ Labelmaker is a wholesale replacement for Foundry Nuke's autolabel system, showing you far more information at a glance in the node graph. No more opening the properties panel to see what a node is doing — Labelmaker shows you right in the DAG. -![Example of what Labelmaker does.](https://github.com/charlesangus/Labelmaker/blob/assets/example.png?raw=true) +![Example of what Labelmaker does.](docs/images/example.png) + +This README is the complete reference. A print-friendly **User Guide** (this document minus the installation section) is built from it as a PDF at `docs/user-guide.pdf` — run `make pdf` to rebuild it — and is bundled in every release. # Installation @@ -14,77 +16,80 @@ Drop the **entire `Labelmaker` folder** (not just individual files) into your `~ nuke.pluginAddPath('Labelmaker') ``` -Note: this goes in `menu.py`, not `init.py`. Labelmaker only affects the UI and does not need to load in headless sessions. +Note: this goes in `menu.py`, not `init.py`. Labelmaker only affects the UI and does not need to load in headless render sessions. ## Facility -Put the `Labelmaker` folder somewhere on a shared location and add it to the Nuke plugin path the same way as above. - -For cascading facility configurations, use the environment variables described in the [Configuration](#configuration) section below. You can layer a facility-wide config on top of the base config, and let artists override further with personal configs. +Put the `Labelmaker` folder somewhere on a shared location and add it to the Nuke plugin path the same way as above. Artists can then layer their own personal config on top of the shared install — see [Configuration](#configuration). # Features -## Node Classes +## Regular Knob Readout -Labelmaker ensures you can always tell what class a node is. +Labelmaker's core job is to read a node's ordinary knob values and print them right on the tile, so you can tell what a node is doing without opening its properties panel. A Blur shows its `size`, a Transform its `translate` and `rotate`, and so on. -![Never wonder what class a node is.](https://github.com/charlesangus/Labelmaker/blob/assets/node_class.png?raw=true) +![Regular knob values are shown right on the node.](docs/images/regular_knobs.png) -If a node has been renamed to something that no longer starts with the class name — for example `Transform1` renamed to `guy` — Labelmaker displays `Transform | guy` so you always know what you're looking at. Nodes that haven't been renamed display normally. +By default a knob line only appears once its value differs from the knob's default, so an untouched node stays clean and an adjusted one announces exactly what changed. You can flip this to always-on in the preferences (see [Always Show All Labels](#preferences)). ## Colour Swatches See the colour of your grades right in the node graph. -![Colourized labels for Color knobs!](https://github.com/charlesangus/Labelmaker/blob/assets/grade.png?raw=true) +![Colourized labels for Color knobs!](docs/images/grade.png) -Color and AColor knobs (e.g. in a Grade node) automatically get colour swatches. Labelmaker uses an approximation of the AlexaToRec curve to tonemap colours so even quite bright values stay legible. Swatch text colour adapts for readability. This can be disabled in the preferences. +Color and AColor knobs (e.g. in a Grade node) automatically get colour swatches. Labelmaker uses an approximation of the AlexaToRec curve to tonemap colours so even quite bright values stay legible. Swatch text colour adapts for readability. This can be disabled globally in the preferences, or per line with the `colorize` config key. -## Channels +## Custom TCL in Autolabels -Any node with a `channels` knob shows what channels it is operating on. +![Execute arbitrary TCL code defined in your Labelmaker config.](docs/images/tcl.png) -![Display channels.](https://github.com/charlesangus/Labelmaker/blob/assets/channels.png?raw=true) +Config entries can include arbitrary TCL strings that are evaluated as if written in the node's label knob. For example, the base config for Shuffle uses `"in [value in]-->out [value out]"`, which displays `in rgba --> out rgba` on the node. This keeps TCL out of the label knob and lets you update the display of every node in every script by editing the config. -## Masks and Unpremults +## Channels, Masks, Merges and Mix -The channel readout updates to reflect masking and unpremult state. +Labelmaker reads the whole channel story off a node — the channels it operates on, any channel mask, (un)premultiplication, mix, and, for Merge-style nodes, the operation and the channels flowing through it — so you can read a composite without opening a single node. -![Clearly display masks and un/premultiplication.](https://github.com/charlesangus/Labelmaker/blob/assets/mask_unpremult.png?raw=true) +![Channels, masks, unpremults, merge operations and mix, all read straight off the nodes.](docs/images/channel_ops.png) -- `M` — masked by a channel -- `Minv` — masked by the inverted channel -- `/*` — unpremultiplied/premultiplied by a channel +- **Channels** — any node with a `channels` knob shows the channels it is operating on, e.g. `(rgb)`. +- **Masks and unpremults** — the channel readout reflects masking and (un)premult state: + - `M` — masked by a channel + - `Minv` — masked by the inverted channel + - `/*` — unpremultiplied/premultiplied by a channel -For example, `(rgb M red) /* alpha` means the node processes `rgb` channels masked by `red` and (un)premultiplied by `alpha`. + For example, `(rgb M red) /* alpha` means the node processes `rgb` channels masked by `red` and (un)premultiplied by `alpha`. +- **Merges and channel operations** — a Merge might read `(rgba) plus (rgba) --> (rgba)`; a non-default `also merge`, `bbox`, `metadata from`, or `range from` appears on its own line when it differs from the default. ChannelMerge and Copy get the same treatment for the channels they route. +- **Mix** — when a node's `mix` knob is set to anything other than `1.0`, the current mix value is shown on the node. -## File Readout +## Regular Old Labels -Read and Write nodes show the basename of their file path directly on the node, so you can identify sources and outputs without opening the properties panel. +The label knob works exactly as before, including TCL expressions, and is shown alongside Labelmaker's readouts. -## Mix +![Regular old labels work exactly as before.](docs/images/regular_label.png) -When a node's `mix` knob is set to anything other than `1.0`, the current mix value is shown on the node. +## Other Readouts -## Custom TCL in Autolabels +A few more things Labelmaker surfaces automatically. -![Execute arbitrary TCL code defined in your Labelmaker config.](https://github.com/charlesangus/Labelmaker/blob/assets/tcl.png?raw=true) +![Node-class disambiguation and Read/Write file basenames.](docs/images/other.png) -Config entries can include arbitrary TCL strings that are evaluated as if written in the node's label knob. For example, the base config for Shuffle uses `"in [value in]-->out [value out]"`, which displays `in rgba --> out rgba` on the node. This keeps TCL out of the label knob and lets you update the display of every node in every script by editing the config. +- **Node class** — Labelmaker ensures you can always tell what class a node is. If a node has been renamed to something that no longer starts with the class name — for example `Transform1` renamed to `guy` — Labelmaker displays `Transform | guy` so you always know what you're looking at. Nodes that haven't been renamed display normally. +- **File readout** — Read and Write nodes show the basename of their file path directly on the node, so you can identify sources and outputs without opening the properties panel. Write nodes also show their colorspace, file type, and render order when they differ from the defaults. -## Regular Old Labels +## Auto De-overlap -The label knob works exactly as before, including TCL expressions. +When a node's label grows taller (because more knob values come into view), Labelmaker automatically pushes any nodes the grown label now overlaps down to make room, whether or not they are connected to the grown node. Pushed nodes cascade: if pushing a node makes it overlap nodes below it, those are pushed too. -![Regular old labels work exactly as before.](https://github.com/charlesangus/Labelmaker/blob/assets/regular_label.png?raw=true) +The push is a spatial sweep — nodes are moved based on where they sit in the DAG, not on how they are wired — and it is debounced with a 150 ms delay so a burst of label changes resolves in one pass. It does not pollute the undo stack. Nodes are never retracted when a label shrinks, side-by-side nodes at the same height are left where they are, and pre-existing overlaps elsewhere in the script are left alone. Backdrops and Viewers are skipped. This feature can be toggled in preferences. -## Auto De-overlap +## De-overlap All Nodes -When a node's label grows taller (because more knob values come into view), Labelmaker automatically pushes any nodes the grown label now overlaps down to make room, whether or not they are connected to the grown node. Pushed nodes cascade: if pushing a node makes it overlap nodes below it, those are pushed too. The push is debounced with a 150ms delay and does not pollute the undo stack. Nodes are not retracted when a label shrinks, and pre-existing overlaps elsewhere in the script are left alone. This feature can be toggled in preferences. +**Edit > Node Layout > De-overlap All Nodes** runs a one-shot spatial sweep across the entire script. Nodes are processed in top-to-bottom order; any node whose bounding box overlaps the node above it is pushed down to clear it, and the push cascades to nodes below. Unlike the automatic push, this operation is fully undoable. -## De-overlap All Nodes +![A tidy node graph after de-overlap.](docs/images/deoverlap.png) -**Edit > Node Layout > De-overlap All Nodes** runs a one-shot spatial sweep across the entire script. Nodes are processed in top-to-bottom order; any node whose bounding box overlaps the node above it is pushed down to clear it. This operation is fully undoable. +This is especially handy the first time you open an existing script after enabling Labelmaker: because the new labels are taller than Nuke's defaults, nodes that used to sit clear of one another may now overlap, and a single De-overlap All pass tidies the whole script at once. # Configuration @@ -114,19 +119,10 @@ Keys for knob entries: | `label` | no | Display label shown in the DAG | | `default` | no | Skip this line when the knob is at this value | | `always_show` | no | Show this line even when the value matches `default` | +| `colorize` | no | Color/AColor knobs are colourised by default; set `false` to disable for a line | | `tcl_string` | — | Alternative to `name`; raw TCL evaluated in node context | -## Config Cascade - -Configs are layered in this order (later entries override earlier ones): - -1. Base config shipped with Labelmaker (`base_config.json`), if enabled -2. Facility configs, in order, from `LABELMAKER_CONFIGS_NAMES` / `LABELMAKER_CONFIGS_PATHS` -3. Personal config at `~/.nuke/labelmaker_config.json` (or the path set in preferences) - -Overriding happens at the **node-class** level, not the individual-line level: if your -personal config defines `Grade`, it replaces the entire `Grade` definition from the base -config rather than merging line by line. +The shipped `base_config.json` already covers many common classes — Read, Write, Grade, Transform, Blur and other filters, Merge2, ChannelMerge, Copy, Shuffle, Reformat, ColorCorrect, Retime, FrameRange, Roto/RotoPaint, and more — so most scripts show rich labels out of the box. A personal config at `~/.nuke/labelmaker_config.json` (or the path set in preferences) layers on top of it; because overriding happens at the node-class level, defining a class in your personal config replaces that whole class definition rather than merging line by line. ## Config Editor @@ -134,6 +130,8 @@ Open the editor via **Edit > Labelmaker Config Editor...** to add and tune label a GUI instead of hand-editing JSON. It's a floating, non-modal window, so you can keep working in the DAG while it's open. +![The Labelmaker Config Editor.](docs/images/config_editor.png) + - Pick which layer to edit from the **Editing layer** dropdown. Read-only layers (such as a base config on a shared install) are shown as `READ-ONLY` and can be browsed but not changed. @@ -147,29 +145,22 @@ working in the DAG while it's open. - **Save** writes the layer to disk and reloads the live autolabeller, so changes apply without restarting Nuke. If your personal config doesn't exist yet, saving creates it. -## Environment Variables - -| Variable | Description | -|---|---| -| `LABELMAKER_DEFAULT_CONFIG_PATH` | Override the default personal config path | -| `LABELMAKER_DISABLE_BASE_CONFIG` | Set to `1` to skip the base config entirely | -| `LABELMAKER_CONFIGS_NAMES` | Semicolon-separated (Windows) or colon-separated (Mac/Linux) list of config names | -| `LABELMAKER_CONFIGS_PATHS` | Matching list of paths for the configs named above | - -# Preferences +## Preferences Open the preferences dialog via **Edit > Labelmaker Preferences...** +![The Labelmaker Preferences dialog.](docs/images/prefs_dialog.png) + | Preference | Default | Description | |---|---|---| -| Enable Labelmaker | on | Master on/off switch | +| Enable Labelmaker | on | Master on/off switch. When off, Nuke's default autolabel is restored. | | Always Show All Labels | off | Show all config lines regardless of whether values are at their defaults | | Disable Colorization | off | Turn off colour swatches | | Use Base Config | on | Include the shipped `base_config.json` | | Enable Auto De-overlap | on | Automatically push overlapped nodes down when a label grows taller | | Personal Config Path | `~/.nuke/labelmaker_config.json` | Location of your personal config overrides | -Preferences are saved to `~/.nuke/labelmaker_prefs.json`. +Preferences are saved to `~/.nuke/labelmaker_prefs.json` and apply immediately. # Caveats @@ -182,7 +173,3 @@ If this bothers you, enable **Always Show All Labels** in the preferences. Nodes ## Performance Labelmaker has been used on production scripts of substantial size without issue. The autolabel routine runs as a low-priority idle process. If you do encounter performance problems, please open a GitHub issue and include the approximate node count and any node class that seems to be the culprit. - -# Contributing - -Pull requests welcome. Please rebase and squash your commits before submitting. diff --git a/docs/images/.gitkeep b/docs/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/images/channel_ops.png b/docs/images/channel_ops.png new file mode 100644 index 0000000..28e5627 Binary files /dev/null and b/docs/images/channel_ops.png differ diff --git a/docs/images/config_editor.png b/docs/images/config_editor.png new file mode 100644 index 0000000..7f66a41 Binary files /dev/null and b/docs/images/config_editor.png differ diff --git a/docs/images/deoverlap.png b/docs/images/deoverlap.png new file mode 100644 index 0000000..a540d87 Binary files /dev/null and b/docs/images/deoverlap.png differ diff --git a/docs/images/example.png b/docs/images/example.png new file mode 100644 index 0000000..b567b8a Binary files /dev/null and b/docs/images/example.png differ diff --git a/docs/images/grade.png b/docs/images/grade.png new file mode 100644 index 0000000..d9af28a Binary files /dev/null and b/docs/images/grade.png differ diff --git a/docs/images/other.png b/docs/images/other.png new file mode 100644 index 0000000..c2aace3 Binary files /dev/null and b/docs/images/other.png differ diff --git a/docs/images/prefs_dialog.png b/docs/images/prefs_dialog.png new file mode 100644 index 0000000..27389c2 Binary files /dev/null and b/docs/images/prefs_dialog.png differ diff --git a/docs/images/regular_knobs.png b/docs/images/regular_knobs.png new file mode 100644 index 0000000..de901ba Binary files /dev/null and b/docs/images/regular_knobs.png differ diff --git a/docs/images/regular_label.png b/docs/images/regular_label.png new file mode 100644 index 0000000..541aa45 Binary files /dev/null and b/docs/images/regular_label.png differ diff --git a/docs/images/tcl.png b/docs/images/tcl.png new file mode 100644 index 0000000..50c1233 Binary files /dev/null and b/docs/images/tcl.png differ diff --git a/docs/latex/logo.pdf b/docs/latex/logo.pdf new file mode 100644 index 0000000..cfa9a02 Binary files /dev/null and b/docs/latex/logo.pdf differ diff --git a/docs/latex/training_doc.cls b/docs/latex/training_doc.cls new file mode 100644 index 0000000..ad57564 --- /dev/null +++ b/docs/latex/training_doc.cls @@ -0,0 +1,149 @@ +% training document template + +\NeedsTeXFormat{LaTeX2e} +% Class name must match the filename (training_doc.cls) and the pandoc +% `documentclass: training_doc` in docs/pandoc/pdf.yaml, or LaTeX warns that the +% requested class does not match the one provided. +\ProvidesClass{training_doc} + + +% Load the Base Class +\LoadClassWithOptions{scrartcl} + +% Begin Requirements +\RequirePackage{ifthen} + +% set font encoding for PDFLaTeX, XeLaTeX, or LuaTeX +\usepackage{ifxetex,ifluatex} +\if\ifxetex T\else\ifluatex T\else F\fi\fi T% + \usepackage{fontspec} + % Pandoc's LaTeX template unconditionally emits \usepackage{lmodern}, a pdfTeX + % Type1 font package. This document renders with xelatex + fontspec, which + % already selects Latin Modern, so lmodern is redundant here and would only be + % an extra build dependency (its .sty would have to be installed purely to be + % ignored). Mark it as already-loaded so the template's later + % \usepackage{lmodern} is a harmless no-op. The pdfTeX branch below still loads + % lmodern for real, since there it is the actual font package. + \@ifundefined{ver@lmodern.sty}{% + \expandafter\def\csname ver@lmodern.sty\endcsname{0000/00/00}}{} +\else + \usepackage[T1]{fontenc} + \usepackage[utf8]{inputenc} + \usepackage{lmodern} +\fi + +% Set the Paper Size and margins +\RequirePackage{geometry} +\geometry{margin=1.0in} + +\RequirePackage{needspace} + +\RequirePackage{float} + +\RequirePackage{caption} +\RequirePackage{subcaption} +\RequirePackage[section]{placeins} + +\RequirePackage{graphicx} + +\RequirePackage{amsmath} + +% quotes in italics +\RequirePackage{etoolbox} +\AtBeginEnvironment{quote}{\itshape} + +% set up headers +\RequirePackage{fancyhdr} +\pagestyle{fancy} +\renewcommand{\headrulewidth}{0pt} +\lhead{\@author} +\rhead{\@title} + +% tighten list formatting - Pandoc's tightlist doesn't quite work +\RequirePackage{enumitem} +\setlist{nosep} + +% Add Department Command +\def\@department{\relax} +\newcommand{\department}[1]{\gdef\@department{#1}} + +% needed for squarepipe, used as alt l3 list item ident +\usepackage{amssymb} + +% KOMA Options +\KOMAoptions{} +% \setcounter{secnumdepth}{0} +\addtokomafont{disposition}{\rmfamily} +\addtokomafont{disposition}{\mdseries} +\addtokomafont{descriptionlabel}{\rmfamily\scshape} +\renewcommand\labelitemii{$\circ$} +% untested, but should be a hollow square +\renewcommand\labelitemiii{\tiny$\square$} +\addtokomafont{paragraph}{\rmfamily\scshape} + +% use small caps for emphasis, and italic for strong +\let\textitold\textit +\let\emphold\emph +\renewcommand{\emph}{\textsc} +\renewcommand{\textit}{\textsc} +\renewcommand{\textbf}{\textitold} + +% Custom Title +\renewcommand{\maketitle}{ +\noindent +\begin{minipage}[c]{0.2\textwidth} + \vspace{0pt} + \begin{center} + \includegraphics[width=\textwidth]{logo} + \end{center} +\end{minipage}\hfill% +\begin{minipage}[c]{0.75\textwidth} + \vspace{0pt} + {\huge \@title \par} + \medskip + {\large \@department\hspace{.5em}\textperiodcentered\hspace{.5em}\@author} + \vfill +\end{minipage}\hfill +\vspace{2\baselineskip} +\thispagestyle{plain} +} + +% required for PanDoc +\providecommand{\tightlist}{} + +% how deep to go in toc +\setcounter{tocdepth}{3} + +% start the body on a fresh page after the table of contents +\let\oldtableofcontents\tableofcontents +\renewcommand{\tableofcontents}{\oldtableofcontents\clearpage} + +% widow/orphan protection: never leave the first/last line of a paragraph +% stranded alone at the top or bottom of a page. +\clubpenalty=10000 +\widowpenalty=10000 +\displaywidowpenalty=10000 +\brokenpenalty=10000 + +% keep headings with the text that follows them, so a heading is never left +% alone at the foot of a page (needspace forces a page break if too few lines +% remain for the heading plus a couple of lines of its section). +% +% The \if@nobreak guard matters: LaTeX sets @nobreak true immediately after a +% sectioning heading, which normally glues a section heading to a subsection +% heading that follows it with no text in between. A bare \needspace injects a +% breakable penalty that would defeat that glue and strand, say, a "Features" +% heading at the foot of a page while its first subsection starts the next one. +% So we only reserve space when we are NOT right after another heading. +\let\oldsection\section +\renewcommand{\section}{\if@nobreak\else\needspace{6\baselineskip}\fi\oldsection} +\let\oldsubsection\subsection +\renewcommand{\subsection}{\if@nobreak\else\needspace{6\baselineskip}\fi\oldsubsection} +\let\oldparagraph\paragraph +\renewcommand{\paragraph}{\if@nobreak\else\needspace{3\baselineskip}\fi\oldparagraph} + +\newcommand\sbullet[1][.5]{\mathbin{\vcenter{\hbox{\scalebox{#1}{$\bullet$}}}}} + +% enable "max width=" in graphcis + +\usepackage[export]{adjustbox} diff --git a/docs/pandoc/build_user_guide_md.py b/docs/pandoc/build_user_guide_md.py new file mode 100644 index 0000000..3460e40 --- /dev/null +++ b/docs/pandoc/build_user_guide_md.py @@ -0,0 +1,76 @@ +"""Derive the User Guide Markdown from README.md for PDF rendering. + +`make pdf` builds a single PDF, docs/user-guide.pdf, from the project README so +the README stays the one source of truth. The User Guide is the README with a +few edits that only make sense for a standalone printed document: + + * a pandoc title block (title / subtitle / author) is prepended, so the PDF + gets Labelmaker's LaTeX title page instead of a bare "Labelmaker" heading; + * the top-level "# Labelmaker" heading is replaced by an "# Introduction" + heading (the title block already carries the Labelmaker title), so the intro + paragraph and hero image get their own numbered section after the contents; + * the line that points at the PDF is dropped — inside the PDF it would be + pointing at itself; + * the whole "# Installation" section is dropped, because the User Guide is + distributed to people who already have Labelmaker installed. + +Usage: + python3 docs/pandoc/build_user_guide_md.py README.md docs/.build/user-guide.md +""" +import sys + +TITLE_BLOCK = """\ +--- +title: "Labelmaker — User Guide" +subtitle: "Features, configuration, and preferences" +author: "Labelmaker" +--- +""" + +INTRO_HEADING = "# Labelmaker" +INSTALL_HEADING = "# Installation" +SELF_REFERENCE_MARKER = "docs/user-guide.pdf" + + +def build_user_guide(readme_text): + output_lines = [TITLE_BLOCK] + is_inside_install_section = False + + for line in readme_text.splitlines(): + is_top_level_heading = line.startswith("# ") + + if is_inside_install_section: + # The install section runs until the next top-level heading. + if is_top_level_heading and line != INSTALL_HEADING: + is_inside_install_section = False + else: + continue + + if line == INSTALL_HEADING: + is_inside_install_section = True + continue + + if line == INTRO_HEADING: + # The title block already carries the Labelmaker title, so give the + # intro paragraph and hero image their own "Introduction" section. + output_lines.append("# Introduction") + continue + + if SELF_REFERENCE_MARKER in line: + continue + + output_lines.append(line) + + return "\n".join(output_lines) + "\n" + + +def main(): + readme_path, output_path = sys.argv[1], sys.argv[2] + with open(readme_path, encoding="utf-8") as readme_file: + readme_text = readme_file.read() + with open(output_path, "w", encoding="utf-8") as output_file: + output_file.write(build_user_guide(readme_text)) + + +if __name__ == "__main__": + main() diff --git a/docs/pandoc/float-images.lua b/docs/pandoc/float-images.lua new file mode 100644 index 0000000..2dd1121 --- /dev/null +++ b/docs/pandoc/float-images.lua @@ -0,0 +1,217 @@ +--- Anchor standalone screenshots beside their text, flush right at half width. +--- +--- The README embeds each screenshot as an image on its own line, which pandoc +--- turns into a full-width, centred figure. For the printed User Guide we want the +--- body text on the left and the screenshot pinned to the right margin at half the +--- text width, anchored inside the section it documents, e.g. +--- +--- ## Colour +--- bla bla bla bla +-------+ +--- bla bla bla bla | img | +--- bla bla bla +-------+ +--- +--- An earlier version floated the images with wrapfig. That looked right when an +--- image was about as tall as the surrounding text, but Labelmaker's node captures +--- are tall (lots of dark DAG margin) while most sections have only a line or two +--- of text — so the floats overflowed down into the *next* section, got deferred +--- to the following page detached from their text, and collided with the page +--- footer. wrapfig cannot be anchored, so we do not use it. +--- +--- Instead each screenshot is paired with an adjacent paragraph in a two-column +--- `minipage` row: text in the left column, image in the right. A minipage is +--- placed exactly where it is written and never floats, so an image can never +--- drift out of its section or onto the footer. When a page break falls inside a +--- row the whole row moves together to the next page (a clean break, not a gap). +--- +--- Pairing: an image is put in a row with the paragraph that immediately follows +--- it (the detail paragraph), or, failing that, the paragraph immediately before +--- it (the intro sentence). The very first image is the wide hero DAG, which is +--- centred full width instead. An image with no adjacent paragraph (e.g. one that +--- sits alone before a heading or a list) is centred, capped at half width. +--- +--- Sizes are emitted explicitly (`max width` / `max totalheight`, from adjustbox's +--- [export] option); the `\setkeys{Gin}{...}` default in pdf.yaml is silently +--- ignored by graphicx for bare \includegraphics on some TeX Live builds, which is +--- what let screenshots overflow the page — so we never rely on it. max-* only +--- ever shrinks, so genuinely small captures keep their natural size. + +-- The right (image) column of a paired row, pinned flush to the right margin, +-- and the fixed gutter separating it from the text. The left (text) column takes +-- whatever is left over so text + gutter + image exactly fills \linewidth: this +-- keeps the image flush right while guaranteeing the text never crowds it. +local IMAGE_COLUMN_WIDTH = "0.46\\linewidth" +local IMAGE_GUTTER = "2em" +local TEXT_COLUMN_WIDTH = "\\dimexpr\\linewidth-" .. IMAGE_COLUMN_WIDTH .. "-" .. IMAGE_GUTTER .. "\\relax" + +-- Cap the image inside its column, and cap its height so a tall portrait capture +-- cannot make a row taller than a page (which would force an ugly early break). +local ROW_IMAGE_KEYS = "max width=\\linewidth, max totalheight=0.34\\textheight" +-- The hero and lone/centred images may use more of the width but stay height-capped. +local HERO_IMAGE_KEYS = "max width=\\linewidth, max totalheight=0.42\\textheight" +local CENTRED_IMAGE_KEYS = "max width=0.5\\linewidth, max totalheight=0.42\\textheight" + +--- Return the Image if `block` is a standalone-image paragraph (pandoc's +--- implicit-figure shape), otherwise nil. +local function standalone_image(block) + if block.t == "Para" and #block.content == 1 and block.content[1].t == "Image" then + return block.content[1] + end + return nil +end + +local function raw_latex(text) + return pandoc.RawBlock("latex", text) +end + +--- A small italic caption line, or "" when the image had no alt text. +local function caption_latex(caption) + if caption == "" then + return "" + end + return "\\par\\vspace{3pt}{\\footnotesize\\itshape " .. caption .. "}" +end + +--- The image half of a paired row: a right-hand minipage holding the screenshot. +local function image_column_latex(image, caption) + return "\\end{minipage}\\hspace{" .. IMAGE_GUTTER .. "}%\n" + .. "\\begin{minipage}[t]{" .. IMAGE_COLUMN_WIDTH .. "}\\vspace{0pt}\\centering\n" + .. "\\includegraphics[" .. ROW_IMAGE_KEYS .. "]{" .. image.src .. "}" + .. caption_latex(caption) .. "\n" + .. "\\end{minipage}\\par\\medskip" +end + +--- Emit a two-column row `[text_paragraph | image]` into `output_blocks`. +--- The text paragraph is inserted unchanged so pandoc renders its inline markup; +--- it is only bracketed by the minipage LaTeX. +local function insert_paired_row(output_blocks, text_paragraph, image, caption) + output_blocks:insert(raw_latex( + "\\medskip\\noindent\\begin{minipage}[t]{" .. TEXT_COLUMN_WIDTH .. "}\\vspace{0pt}")) + output_blocks:insert(text_paragraph) + output_blocks:insert(raw_latex(image_column_latex(image, caption))) +end + +--- Emit a non-floating centred image (used for the hero and for lone images that +--- have no adjacent paragraph to sit beside). +local function insert_centred_image(output_blocks, image, caption, size_keys) + output_blocks:insert(raw_latex( + "\\begin{center}\n" + .. "\\includegraphics[" .. size_keys .. "]{" .. image.src .. "}" + .. caption_latex(caption) .. "\n" + .. "\\end{center}")) +end + +--- GFM pipe tables carry no column widths, so pandoc's LaTeX writer emits plain +--- `l` columns that never wrap — a wide Description column then runs off the page. +--- Assigning explicit relative widths makes the writer use wrapping paragraph +--- (`p{}`) columns instead. +--- +--- We weight each column by how much width its content actually needs: +--- * The longest *word* (unbreakable token, e.g. an env-var name or code label) +--- is a hard floor — prose wraps, but a single long word cannot, so a column +--- must be at least wide enough for it or its text spills into the next column. +--- * The longest *cell* is capped before it counts, so one long Description +--- sentence (which wraps freely) does not starve the short label columns. +--- The per-column weight is the larger of the two, normalised to leave a margin. +--- +--- Long words are mostly code tokens (config paths, env-var names) typeset in a +--- monospace font, whose glyphs are wider than the proportional body font a raw +--- character count assumes. We scale the longest-word floor up to compensate; +--- because it competes with the capped cell length via max(), this only bites for +--- genuinely long tokens — the columns that would otherwise overflow. +local CELL_LENGTH_CAP = 30 +local MONOSPACE_WORD_WIDTH_FACTOR = 1.3 + +function Table(table_element) + local column_count = #table_element.colspecs + if column_count == 0 then + return nil + end + + local longest_word_length = {} + local longest_cell_length = {} + for column_index = 1, column_count do + longest_word_length[column_index] = 0 + longest_cell_length[column_index] = 0 + end + + local function measure_row(row) + for column_index, cell in ipairs(row.cells) do + local cell_text = pandoc.utils.stringify(cell.contents) + if #cell_text > longest_cell_length[column_index] then + longest_cell_length[column_index] = #cell_text + end + for word in cell_text:gmatch("%S+") do + if #word > longest_word_length[column_index] then + longest_word_length[column_index] = #word + end + end + end + end + + for _, row in ipairs(table_element.head.rows) do + measure_row(row) + end + for _, body in ipairs(table_element.bodies) do + for _, row in ipairs(body.body) do + measure_row(row) + end + end + + local column_weight = {} + local total_weight = 0 + for column_index = 1, column_count do + local capped_cell_length = math.min(longest_cell_length[column_index], CELL_LENGTH_CAP) + local word_floor = longest_word_length[column_index] * MONOSPACE_WORD_WIDTH_FACTOR + -- At least as wide as the longest unbreakable word; floored so an empty + -- column still gets a usable sliver. + column_weight[column_index] = math.max(word_floor, capped_cell_length, 3) + total_weight = total_weight + column_weight[column_index] + end + + -- Fill 94% of the line width; the remainder covers inter-column padding so the + -- table never spills past the right margin. + local usable_fraction = 0.94 + for column_index, colspec in ipairs(table_element.colspecs) do + colspec[2] = usable_fraction * column_weight[column_index] / total_weight + end + + return table_element +end + +function Pandoc(doc) + local blocks = doc.blocks + local output_blocks = pandoc.List() + local hero_done = false + local i = 1 + while i <= #blocks do + local image = standalone_image(blocks[i]) + if not image then + output_blocks:insert(blocks[i]) + i = i + 1 + elseif not hero_done then + -- The first screenshot is the wide hero DAG: centre it full width. + hero_done = true + insert_centred_image(output_blocks, image, pandoc.utils.stringify(image.caption), HERO_IMAGE_KEYS) + i = i + 1 + else + local caption = pandoc.utils.stringify(image.caption) + local following_block = blocks[i + 1] + local preceding_block = output_blocks[#output_blocks] + if following_block ~= nil and following_block.t == "Para" then + -- Pair with the detail paragraph that follows the image. + insert_paired_row(output_blocks, following_block, image, caption) + i = i + 2 + elseif preceding_block ~= nil and preceding_block.t == "Para" then + -- No following paragraph: pair with the intro sentence just above. + output_blocks:remove(#output_blocks) + insert_paired_row(output_blocks, preceding_block, image, caption) + i = i + 1 + else + -- Lone image with no text to sit beside: centre it at half width. + insert_centred_image(output_blocks, image, caption, CENTRED_IMAGE_KEYS) + i = i + 1 + end + end + end + return pandoc.Pandoc(output_blocks, doc.meta) +end diff --git a/docs/pandoc/pdf.yaml b/docs/pandoc/pdf.yaml new file mode 100644 index 0000000..9966ce4 --- /dev/null +++ b/docs/pandoc/pdf.yaml @@ -0,0 +1,30 @@ +# pandoc defaults for building the documentation PDFs. +# Used by `make pdf`: pandoc --defaults docs/pandoc/pdf.yaml -o out.pdf in.md +# +# PDFs are styled with the project's LaTeX class, docs/latex/training_doc.cls +# (scrartcl-based, fontspec/xelatex, logo title block). The Makefile adds +# docs/latex to TEXINPUTS so the class and logo are found. +from: gfm +pdf-engine: xelatex +toc: true +toc-depth: 2 +number-sections: true +variables: + documentclass: training_doc + colorlinks: true + linkcolor: "RoyalBlue" + urlcolor: "RoyalBlue" + # Screenshot layout is owned by docs/pandoc/float-images.lua: it anchors each + # screenshot beside its paragraph in a two-column minipage row (text left, image + # pinned right at half the text width) and centres the hero image. minipages do + # not float, so images stay in their section instead of drifting onto the footer + # or the next section. That filter emits the size caps *explicitly* per image + # (max width / max totalheight, from adjustbox's [export] option); it does not + # rely on the \setkeys{Gin}{...} default below, which graphicx silently ignores + # for bare \includegraphics on some TeX Live builds — the bug that let wide + # captures run off the page. adjustbox ships in texlive-latex-extra. max-* only + # shrinks, never upscales, so small captures keep their natural size. + header-includes: + - | + \usepackage[export]{adjustbox} + \setkeys{Gin}{max width=0.5\linewidth, max totalheight=0.42\textheight} diff --git a/docs/screenshots/bootstrap/menu.py b/docs/screenshots/bootstrap/menu.py new file mode 100644 index 0000000..37297c2 --- /dev/null +++ b/docs/screenshots/bootstrap/menu.py @@ -0,0 +1,83 @@ +"""Capture-session bootstrap for nuke-screenshotter. + +Put this folder on NUKE_PATH (see the repo Makefile) so the screenshotter's +full-GUI Nuke session sources this menu.py, which: + + 1. Loads Labelmaker the way a real user installs it (nuke.pluginAddPath), so the + DAG shows Labelmaker autolabels and the Edit > Labelmaker commands exist. + 2. Disables Labelmaker's auto-deoverlap for the session, so refreshing labels + does not move nodes (which would shift them out of the captured backdrop + regions and can cascade). + +Forcing the autolabels to redraw used to be this bootstrap's job too: Nuke only +computes a node's full label once the viewport has been centred on it at a +high-enough zoom, so a plain backdrop grab captured stale (class-name-only) +labels. We worked around it by cutting and pasting every node on script load. +nuke-screenshotter v1.2 (#6) fixes this in the capture path itself — it +runs a warm-up pass that centres each in-backdrop node at the render zoom so +Nuke caches the full label before the grab — so the workaround is no longer +needed and has been removed. + +This is a menu.py (not init.py): Labelmaker calls GUI-only APIs (nuke.toolbar) at +import, which only work once the GUI is up (the menu phase). init.py is also +sourced by non-GUI render workers, where that import would crash. +""" +import os +import sys + +import nuke + +bootstrap_dir = os.path.dirname(os.path.abspath(__file__)) +labelmaker_dir = os.path.dirname(os.path.dirname(os.path.dirname(bootstrap_dir))) + +if labelmaker_dir not in sys.path: + sys.path.insert(0, labelmaker_dir) +nuke.pluginAddPath(labelmaker_dir) + +try: + import labelmaker # noqa: F401 registers the autolabel (enabled by default) + import labelmaker_config_editor # noqa: F401 Edit > Labelmaker Config Editor + import labelmaker_deoverlap # noqa: F401 + import labelmaker_prefs # noqa: F401 + import labelmaker_prefs_dialog # noqa: F401 Edit > Labelmaker Preferences + + # Auto-deoverlap would move nodes as labels grow during the forced refresh. + labelmaker_prefs.prefs_singleton._prefs["deoverlap_enabled"] = False + + # Capture-only shortcuts for the panel scenarios (panels.scenarios.json). + # The scenarios must open each dialog deterministically. Driving the Edit menu + # by typing the item name is not viable: the screenshotter types into the live + # focusWidget, which in an idle session is the DAG — so the letters become node + # hotkeys (e.g. "E" triggers a personal ~/.nuke plugin and crashes the capture). + # Instead we register a unique, collision-free chord per dialog and the scenario + # presses just that chord; no letters ever reach the DAG. + # The real prefs entry point (show_prefs_dialog) calls dialog.exec(), which is + # modal and blocks Nuke's event loop — the playback runner would hang there + # until the capture times out. For the capture we show the same dialog + # non-modally instead; the reference is held so Qt does not garbage-collect it + # while it is on screen. (show_config_editor is already non-modal.) + captured_dialogs = [] + + def _show_prefs_dialog_nonmodal(): + prefs_dialog = labelmaker_prefs_dialog.LabelmakerPrefsDialog() + captured_dialogs.append(prefs_dialog) + prefs_dialog.show() + + capture_menu = nuke.menu("Nuke").addMenu("LabelmakerCapture") + capture_menu.addCommand( + "Open Preferences", + _show_prefs_dialog_nonmodal, + "Ctrl+Alt+Shift+P", + ) + capture_menu.addCommand( + "Open Config Editor", + labelmaker_config_editor.show_config_editor, + "Ctrl+Alt+Shift+C", + ) + + sys.stderr.write("LABELMAKER_BOOTSTRAP_OK\n") + sys.stderr.flush() +except Exception: + import traceback + sys.stderr.write("LABELMAKER_BOOTSTRAP_FAIL\n" + traceback.format_exc()) + sys.stderr.flush() diff --git a/docs/screenshots/build_features_nk.py b/docs/screenshots/build_features_nk.py new file mode 100644 index 0000000..e4f9a10 --- /dev/null +++ b/docs/screenshots/build_features_nk.py @@ -0,0 +1,210 @@ +"""Build docs/screenshots/features.nk — the DAG that the screenshotter captures. + +Run headlessly to regenerate the .nk after changing which features are shown: + + nuke -t docs/screenshots/build_features_nk.py + +Each cluster of nodes is wrapped in a BackdropNode whose label starts with +``screenshot:``; nuke-screenshotter turns one backdrop into one PNG named +after the slug of the label (e.g. ``screenshot:regular knobs`` -> ``regular_knobs.png``). +Knob values are chosen so the relevant Labelmaker lines appear on each node. + +The committed features.nk is what the screenshotter consumes — Nuke is only needed +to regenerate it, not to use it. +""" +import os + +import nuke + +# Grid layout: clusters are laid out in a grid of cells; each cell holds one +# cluster. The backdrop is NOT the cell — it is sized to hug the cluster's nodes +# (see wrap_backdrop) so the screenshotter, which crops to the backdrop, frames +# each example the same way regardless of how many nodes it has. Coordinates are +# DAG units. Cells are only spacers: they must be larger than the biggest +# backdrop so neighbouring backdrops never overlap. +CELL_WIDTH = 560 +CELL_HEIGHT = 740 +COLUMNS = 4 +NODE_Y_STEP = 95 + +# Where the first node of a cluster sits inside its cell. Chosen so the backdrop +# (node bounding box grown by BACKDROP_MARGIN, plus the label/arrow allowances +# below) starts at the cell's own origin and stays inside the cell. +NODE_X_OFFSET = 150 +NODE_Y_OFFSET = 165 + +# The captured backdrop = the cluster's node bounding box, grown by this margin +# on every side so the example is centred with even breathing room around it. +BACKDROP_MARGIN = 150 + +# A bare node tile is roughly this size in DAG units; Nuke reports 0 for +# screenWidth/screenHeight in headless `nuke -t`, so we use nominal values. +NODE_WIDTH = 80 +NODE_HEIGHT = 18 + +# Labelmaker's autolabel draws several lines BELOW the tile and Nuke draws an +# input-arrow stub ABOVE the top node. Those are not loaded when this script +# builds the .nk, so we reserve room for them explicitly; this keeps the visible +# content (arrow + tiles + label block) centred inside the margin rather than +# the bare tiles. +LABEL_BLOCK_BELOW = 60 +INPUT_ARROW_ABOVE = 15 + +# Shift the whole grid off the origin. Nuke omits xpos/ypos knobs when they are 0 +# (their default), and the screenshotter's parser skips any backdrop missing those +# knobs — so a backdrop whose left/top edge lands on 0 silently drops out of the +# capture. Keeping every coordinate positive and non-zero avoids that. +GRID_ORIGIN_X = 100 +GRID_ORIGIN_Y = 100 + +_cluster_index = 0 + + +def _cell_origin(index): + column = index % COLUMNS + row = index // COLUMNS + return GRID_ORIGIN_X + column * CELL_WIDTH, GRID_ORIGIN_Y + row * CELL_HEIGHT + + +def start_cluster(): + """Return (node_x, node_top) — the top-left anchor for the next cluster's nodes.""" + global _cluster_index + origin_x, origin_y = _cell_origin(_cluster_index) + _cluster_index += 1 + return origin_x + NODE_X_OFFSET, origin_y + NODE_Y_OFFSET + + +def place(node, node_x, node_top, row): + node["xpos"].setValue(node_x) + node["ypos"].setValue(node_top + row * NODE_Y_STEP) + return node + + +def wrap_backdrop(label, nodes): + """Draw a screenshot: backdrop hugging ``nodes`` with an even margin around them.""" + min_x = min(node.xpos() for node in nodes) + min_y = min(node.ypos() for node in nodes) + max_x = max(node.xpos() + NODE_WIDTH for node in nodes) + max_y = max(node.ypos() + NODE_HEIGHT for node in nodes) + + left = min_x - BACKDROP_MARGIN + top = min_y - INPUT_ARROW_ABOVE - BACKDROP_MARGIN + right = max_x + BACKDROP_MARGIN + bottom = max_y + LABEL_BLOCK_BELOW + BACKDROP_MARGIN + + backdrop = nuke.nodes.BackdropNode() + backdrop["label"].setValue("screenshot:" + label) + backdrop["note_font_size"].setValue(28) + backdrop["tile_color"].setValue(0x556699FF) + backdrop["xpos"].setValue(int(left)) + backdrop["ypos"].setValue(int(top)) + backdrop["bdwidth"].setValue(int(right - left)) + backdrop["bdheight"].setValue(int(bottom - top)) + return backdrop + + +# --- example (hero): a small connected comp showing several label kinds -------- +node_x, node_top = start_cluster() +read_hero = place(nuke.nodes.Read(), node_x, node_top, 0) +read_hero["file"].setValue("/jobs/ENG/sh010/plates/bg_main_v003.%04d.exr") +grade_hero = place(nuke.nodes.Grade(), node_x, node_top, 1) +grade_hero["white"].setValue([0.85, 0.45, 0.2, 1.0]) +grade_hero["multiply"].setValue([1.1, 0.95, 0.8, 1.0]) +grade_hero.setInput(0, read_hero) +blur_hero = place(nuke.nodes.Blur(), node_x, node_top, 2) +blur_hero["size"].setValue(8) +blur_hero.setInput(0, grade_hero) +write_hero = place(nuke.nodes.Write(), node_x, node_top, 3) +write_hero["file"].setValue("/jobs/ENG/sh010/comp/sh010_comp_v012.%04d.exr") +write_hero.setInput(0, blur_hero) +wrap_backdrop("example", [read_hero, grade_hero, blur_hero, write_hero]) + +# --- regular knobs: Labelmaker reads out ordinary knob values ----------------- +# The core behaviour: adjusted knobs show right on the tile. A Blur's size and a +# Transform's translate/rotate appear without opening a single properties panel. +node_x, node_top = start_cluster() +blur_regular = place(nuke.nodes.Blur(), node_x, node_top, 0) +blur_regular["size"].setValue(8) +transform_regular = place(nuke.nodes.Transform(), node_x, node_top, 1) +transform_regular["translate"].setValue([35, 14]) +transform_regular["rotate"].setValue(8) +transform_regular.setInput(0, blur_regular) +wrap_backdrop("regular knobs", [blur_regular, transform_regular]) + +# --- grade: colour swatches on Color knobs ------------------------------------ +node_x, node_top = start_cluster() +grade_colour = place(nuke.nodes.Grade(), node_x, node_top, 0) +grade_colour["white"].setValue([0.85, 0.4, 0.2, 1.0]) +grade_colour["multiply"].setValue([1.15, 0.95, 0.75, 1.0]) +grade_colour["gamma"].setValue([1.1, 1.0, 0.95, 1.0]) +wrap_backdrop("grade", [grade_colour]) + +# --- tcl: config-driven TCL string on a Shuffle ------------------------------- +node_x, node_top = start_cluster() +shuffle_node = place(nuke.nodes.Shuffle(), node_x, node_top, 0) +wrap_backdrop("tcl", [shuffle_node]) + +# --- channel ops: channels, mask/unpremult and mix on one Grade, plus a Merge -- +# One example carrying every channel-related readout: the Grade shows its channel +# subset, channel mask, (un)premult and mix; the Merge shows its operation and the +# channels flowing through it. +node_x, node_top = start_cluster() +channel_read_a = nuke.nodes.Read() +channel_read_a["file"].setValue("/jobs/ENG/sh010/elements/fx_smoke_v002.%04d.exr") +channel_read_a["xpos"].setValue(node_x + 120) +channel_read_a["ypos"].setValue(node_top) +channel_read_b = place(nuke.nodes.Read(), node_x, node_top, 0) +channel_read_b["file"].setValue("/jobs/ENG/sh010/plates/bg_main_v003.%04d.exr") +channel_grade = place(nuke.nodes.Grade(), node_x, node_top, 2) +channel_grade["channels"].setValue("rgb") +channel_grade["maskChannelInput"].setValue("rgba.red") +channel_grade["unpremult"].setValue("rgba.alpha") +channel_grade["white"].setValue(0.8) +channel_grade["mix"].setValue(0.5) +channel_grade.setInput(0, channel_read_b) +channel_merge = place(nuke.nodes.Merge2(), node_x, node_top, 4) +channel_merge["operation"].setValue("plus") +channel_merge.setInput(0, channel_grade) +channel_merge.setInput(1, channel_read_a) +wrap_backdrop("channel ops", [channel_read_a, channel_read_b, channel_grade, channel_merge]) + +# --- regular label: the node label knob still works --------------------------- +node_x, node_top = start_cluster() +grade_label = place(nuke.nodes.Grade(), node_x, node_top, 0) +grade_label["label"].setValue("key light\nbalance to hero") +grade_label["white"].setValue(0.9) +wrap_backdrop("regular label", [grade_label]) + +# --- other: node-class disambiguation + Read/Write file basenames ------------- +# The grab-bag shot: a renamed Transform reading "Transform | guy" (node class), +# above a Read -> Write chain showing their file basenames (file readout). +node_x, node_top = start_cluster() +transform_named = place(nuke.nodes.Transform(), node_x, node_top, 0) +transform_named.setName("guy") +transform_named["translate"].setValue([35, 14]) +transform_named["rotate"].setValue(8) +other_read = place(nuke.nodes.Read(), node_x, node_top, 2) +other_read["file"].setValue("/jobs/ENG/sh010/plates/bg_main_v003.%04d.exr") +other_write = place(nuke.nodes.Write(), node_x, node_top, 3) +other_write["file"].setValue("/jobs/ENG/sh010/comp/sh010_comp_v012.%04d.exr") +other_write.setInput(0, other_read) +wrap_backdrop("other", [transform_named, other_read, other_write]) + +# --- deoverlap: a clean, tidy vertical chain ---------------------------------- +node_x, node_top = start_cluster() +read_clean = place(nuke.nodes.Read(), node_x, node_top, 0) +read_clean["file"].setValue("/jobs/ENG/sh020/plates/bg_v001.%04d.exr") +grade_clean = place(nuke.nodes.Grade(), node_x, node_top, 1) +grade_clean["white"].setValue(0.95) +grade_clean.setInput(0, read_clean) +blur_clean = place(nuke.nodes.Blur(), node_x, node_top, 2) +blur_clean["size"].setValue(4) +blur_clean.setInput(0, grade_clean) +write_clean = place(nuke.nodes.Write(), node_x, node_top, 3) +write_clean["file"].setValue("/jobs/ENG/sh020/comp/sh020_comp_v001.%04d.exr") +write_clean.setInput(0, blur_clean) +wrap_backdrop("deoverlap", [read_clean, grade_clean, blur_clean, write_clean]) + +output_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "features.nk") +nuke.scriptSaveToTemp(output_path) +print("WROTE " + output_path) diff --git a/docs/screenshots/features.nk b/docs/screenshots/features.nk new file mode 100644 index 0000000..742ba1b --- /dev/null +++ b/docs/screenshots/features.nk @@ -0,0 +1,277 @@ +#! /usr/local/Nuke16.0v6/libnuke-16.0.6.so -nx +version 16.0 v6 +Root { + inputs 0 + gsv { + __default__ { + } +} + format "2048 1556 0 0 2048 1556 1 2K_Super_35(full-ap)" + proxy_type scale + proxy_format "1024 778 0 0 1024 778 1 1K_Super_35(full-ap)" + colorManagement Nuke + workingSpaceLUT 0 + monitorLut sRGB + monitorOutLUT rec709 + int8Lut 0 + int16Lut 0 + logLut 0 + floatLut 0 +} +BackdropNode { + inputs 0 + name BackdropNode1 + tile_color 0x556699ff + label screenshot:example + note_font_size 28 + xpos 100 + ypos 100 + bdwidth 380 + bdheight 678 +} +BackdropNode { + inputs 0 + name BackdropNode2 + tile_color 0x556699ff + label "screenshot:regular knobs" + note_font_size 28 + xpos 660 + ypos 100 + bdwidth 380 + bdheight 488 +} +BackdropNode { + inputs 0 + name BackdropNode3 + tile_color 0x556699ff + label screenshot:grade + note_font_size 28 + xpos 1220 + ypos 100 + bdwidth 380 + bdheight 393 +} +BackdropNode { + inputs 0 + name BackdropNode4 + tile_color 0x556699ff + label screenshot:tcl + note_font_size 28 + xpos 1780 + ypos 100 + bdwidth 380 + bdheight 393 +} +BackdropNode { + inputs 0 + name BackdropNode5 + tile_color 0x556699ff + label "screenshot:channel ops" + note_font_size 28 + xpos 100 + ypos 840 + bdwidth 500 + bdheight 773 +} +BackdropNode { + inputs 0 + name BackdropNode6 + tile_color 0x556699ff + label "screenshot:regular label" + note_font_size 28 + xpos 660 + ypos 840 + bdwidth 380 + bdheight 393 +} +BackdropNode { + inputs 0 + name BackdropNode7 + tile_color 0x556699ff + label screenshot:other + note_font_size 28 + xpos 1220 + ypos 840 + bdwidth 380 + bdheight 678 +} +BackdropNode { + inputs 0 + name BackdropNode8 + tile_color 0x556699ff + label screenshot:deoverlap + note_font_size 28 + xpos 1780 + ypos 840 + bdwidth 380 + bdheight 678 +} +Read { + inputs 0 + file_type exr + file /jobs/ENG/sh010/plates/bg_main_v003.%04d.exr + in_colorspace scene_linear + out_colorspace scene_linear + name Read1 + xpos 250 + ypos 265 +} +Grade { + white {0.85 0.45 0.2 1} + multiply {1.1 0.95 0.8 1} + name Grade1 + xpos 250 + ypos 360 +} +Blur { + size 8 + name Blur1 + xpos 250 + ypos 455 +} +Write { + file /jobs/ENG/sh010/comp/sh010_comp_v012.%04d.exr + in_colorspace scene_linear + out_colorspace scene_linear + ocioColorspace scene_linear + display default + view sRGB + name Write1 + xpos 250 + ypos 550 +} +Blur { + inputs 0 + size 8 + name Blur2 + xpos 810 + ypos 265 +} +Transform { + translate {35 14} + rotate 8 + name Transform1 + xpos 810 + ypos 360 +} +Grade { + inputs 0 + white {0.85 0.4 0.2 1} + multiply {1.15 0.95 0.75 1} + gamma {1.1 1 0.95 1} + name Grade2 + xpos 1370 + ypos 265 +} +Shuffle { + inputs 0 + name Shuffle1 + xpos 1930 + ypos 265 +} +Read { + inputs 0 + file_type exr + file /jobs/ENG/sh010/elements/fx_smoke_v002.%04d.exr + in_colorspace scene_linear + out_colorspace scene_linear + name Read2 + xpos 370 + ypos 1005 +} +Read { + inputs 0 + file_type exr + file /jobs/ENG/sh010/plates/bg_main_v003.%04d.exr + in_colorspace scene_linear + out_colorspace scene_linear + name Read3 + xpos 250 + ypos 1005 +} +Grade { + white 0.8 + maskChannelInput rgba.red + unpremult rgba.alpha + mix 0.5 + name Grade3 + xpos 250 + ypos 1195 +} +Merge2 { + inputs 2 + operation plus + name Merge1 + xpos 250 + ypos 1385 +} +Grade { + inputs 0 + white 0.9 + name Grade4 + label "key light\nbalance to hero" + xpos 810 + ypos 1005 +} +Transform { + inputs 0 + translate {35 14} + rotate 8 + name guy + xpos 1370 + ypos 1005 +} +Read { + inputs 0 + file_type exr + file /jobs/ENG/sh010/plates/bg_main_v003.%04d.exr + in_colorspace scene_linear + out_colorspace scene_linear + name Read4 + xpos 1370 + ypos 1195 +} +Write { + file /jobs/ENG/sh010/comp/sh010_comp_v012.%04d.exr + in_colorspace scene_linear + out_colorspace scene_linear + ocioColorspace scene_linear + display default + view sRGB + name Write2 + xpos 1370 + ypos 1290 +} +Read { + inputs 0 + file_type exr + file /jobs/ENG/sh020/plates/bg_v001.%04d.exr + in_colorspace scene_linear + out_colorspace scene_linear + name Read5 + xpos 1930 + ypos 1005 +} +Grade { + white 0.95 + name Grade5 + xpos 1930 + ypos 1100 +} +Blur { + size 4 + name Blur3 + xpos 1930 + ypos 1195 +} +Write { + file /jobs/ENG/sh020/comp/sh020_comp_v001.%04d.exr + in_colorspace scene_linear + out_colorspace scene_linear + ocioColorspace scene_linear + display default + view sRGB + name Write3 + xpos 1930 + ypos 1290 +} diff --git a/docs/screenshots/panels.scenarios.json b/docs/screenshots/panels.scenarios.json new file mode 100644 index 0000000..684f8dd --- /dev/null +++ b/docs/screenshots/panels.scenarios.json @@ -0,0 +1,30 @@ +[ + { + "name": "labelmaker_preferences", + "steps": [ + { "type": "click", "target": { "object_name": "@dag" } }, + { "type": "keys", "keys": ["Ctrl+Alt+Shift+P"] }, + { "type": "wait", "duration": 1.0 }, + { + "type": "screenshot", + "target": { "object_name": "LabelmakerPrefsDialog", "class_name": "QDialog" }, + "filename": "prefs_dialog.png" + }, + { "type": "keys", "keys": ["Escape"] } + ] + }, + { + "name": "labelmaker_config_editor", + "steps": [ + { "type": "click", "target": { "object_name": "@dag" } }, + { "type": "keys", "keys": ["Ctrl+Alt+Shift+C"] }, + { "type": "wait", "duration": 1.0 }, + { + "type": "screenshot", + "target": { "object_name": "LabelmakerConfigEditor" }, + "filename": "config_editor.png" + }, + { "type": "keys", "keys": ["Escape"] } + ] + } +] diff --git a/docs/user-guide.pdf b/docs/user-guide.pdf new file mode 100644 index 0000000..f5c2c5b Binary files /dev/null and b/docs/user-guide.pdf differ diff --git a/labelmaker_config_editor.py b/labelmaker_config_editor.py index db58d0c..2b353c6 100644 --- a/labelmaker_config_editor.py +++ b/labelmaker_config_editor.py @@ -78,6 +78,7 @@ def _path_is_writable(path): class LabelmakerConfigEditor(QWidget): def __init__(self, parent=None): super().__init__(parent) + self.setObjectName("LabelmakerConfigEditor") self.setWindowFlags(Qt.Tool) self.setWindowTitle("Labelmaker Config Editor") self.setMinimumSize(900, 500) diff --git a/labelmaker_prefs_dialog.py b/labelmaker_prefs_dialog.py index ce0e7a6..6952d34 100644 --- a/labelmaker_prefs_dialog.py +++ b/labelmaker_prefs_dialog.py @@ -21,6 +21,7 @@ class LabelmakerPrefsDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) + self.setObjectName("LabelmakerPrefsDialog") self.setWindowTitle("Labelmaker Preferences") self.setMinimumWidth(500) self._build_ui()