Skip to content
Open
Show file tree
Hide file tree
Changes from 125 commits
Commits
Show all changes
138 commits
Select commit Hold shift + click to select a range
95766e7
docs(specs): specify the GPU pass fusion pipeline
yuto-trd Aug 12, 2026
35e7f28
refactor(engine)!: record then plan the render pipeline and fuse GPU …
yuto-trd Aug 12, 2026
ad27305
test(engine): cover the fused render pipeline
yuto-trd Aug 12, 2026
f9c16ee
fix(review): give drawable-brush thumbnails a target domain
yuto-trd Aug 12, 2026
78b2772
fix(engine): restore drawable-brush stretch against intrinsic bounds
yuto-trd Aug 12, 2026
5c8a7e6
perf(engine): crop drawable-brush content without a device snapshot
yuto-trd Aug 12, 2026
acdf35b
fix(engine): count deferred operations in FilterEffectContext.CountItems
yuto-trd Aug 12, 2026
5625a65
fix(editor): report engine-resource failures instead of killing the r…
yuto-trd Aug 12, 2026
699332c
feat(engine)!: expose drawable-brush materialization and the cache op…
yuto-trd Aug 12, 2026
a7ade16
refactor(engine): name the filter-effect fallback path for what it is
yuto-trd Aug 12, 2026
88d311c
fix(editor): close the failure paths the review fixes opened
yuto-trd Aug 12, 2026
8be2d76
docs(ai-workflow): point effect authors at the recorded surfaces
yuto-trd Aug 12, 2026
e296e21
fix(benchmarks): read pipeline counters from the surviving statistics
yuto-trd Aug 12, 2026
6a09384
test(benchmarks): cover spatial-effect chain depth
yuto-trd Aug 12, 2026
b37927c
perf(engine): reduce filter-effect GPU synchronization
yuto-trd Aug 13, 2026
15cd411
test(engine): drop the orphaned GetMaximumDimension test fakes
yuto-trd Aug 13, 2026
20a9dd0
fix(editor): bound export GPU memory with periodic retention checkpoints
yuto-trd Aug 13, 2026
999ad72
fix(engine)!: fail delivery renders on custom-effect target allocatio…
yuto-trd Aug 13, 2026
20aa0ce
fix(engine): honor the destination clip and preview degradation when …
yuto-trd Aug 13, 2026
ebce996
fix(nodegraph): decide preview emptiness from the resolved output extent
yuto-trd Aug 13, 2026
e4f0f99
fix(editor): rasterize the selected drawable over its output bounds
yuto-trd Aug 13, 2026
d8be641
docs(specs): restore the migration contract and correct the task state
yuto-trd Aug 13, 2026
e3927dd
fix(engine): bound the filter layer to the content being filtered
yuto-trd Aug 13, 2026
991f49e
perf(engine)!: widen GPU pass fusion beyond current-pixel colour shaders
yuto-trd Aug 15, 2026
1bc6920
perf(engine): cut fusion, synchronization and readback overhead
yuto-trd Aug 15, 2026
58d8e0d
fix(engine): synchronize custom-effect sources across the Vulkan boun…
yuto-trd Aug 15, 2026
cfaaad6
test(engine): bound the colour-filter parity oracle to the reference'…
yuto-trd Aug 15, 2026
3dde97c
fix(engine): repair the rendering regressions found by differential a…
Aug 16, 2026
ee50706
fix(engine)!: apply a group's filter per child and eliminate non-fini…
Aug 17, 2026
f1bb791
fix(engine): submit the target clear before a native writer fills it
Aug 18, 2026
bab9370
fix(engine): round a whole-source shader's bounds on the shared devic…
Aug 18, 2026
1db58bc
test(engine): retarget the selected-drawable group test to the group'…
yuto-trd Aug 18, 2026
ebf1bc5
fix(engine): round a callback's own bound out to whole device pixels
yuto-trd Aug 18, 2026
b17fab1
fix(engine): draw a particle's source instead of tiling it as a shader
yuto-trd Aug 18, 2026
7327911
WIP: tasks 1-5 vulkan effect-layer native work
yuto-trd Aug 17, 2026
d6774c6
WIP: rebase integration fixes
yuto-trd Aug 18, 2026
9352a5c
Merge branch 'main' into speckit/004-gpu-pass-fusion-unified
yuto-trd Aug 19, 2026
5d918b9
style(tests): restore the UTF-8 BOM on the native scratch-texture tests
yuto-trd Aug 19, 2026
283cdcc
fix(engine): keep a render pass from swallowing the transfers recorde…
yuto-trd Aug 19, 2026
2974a60
fix(engine)!: stop recording an identity colour matrix as a shader stage
yuto-trd Aug 19, 2026
e90a4d7
test(engine): make the GPU oracles hold on a backend with real fp16
yuto-trd Aug 19, 2026
a9a999a
fix(engine): stop the colour keys from hinging on bit-exact colour eq…
yuto-trd Aug 19, 2026
5d2b603
fix(engine): stop an inset stroke from cropping the fill it surrounds
yuto-trd Aug 19, 2026
6dfd0f2
fix(engine)!: evaluate a whole-source shader over its complete output
yuto-trd Aug 19, 2026
674c82a
fix(engine): bound a perspective quad that crosses the camera plane
yuto-trd Aug 19, 2026
66cd2dc
fix(engine)!: stop a negative morphology radius from cropping the con…
yuto-trd Aug 19, 2026
554bd62
fix(engine): anchor the Skia chain frame to the bounds each item maps
yuto-trd Aug 19, 2026
680ee2c
fix(engine): give the target-rewriting effects a declared output extent
yuto-trd Aug 19, 2026
7e2d928
fix(engine)!: rasterize a filtered hairline on the grid it is composi…
yuto-trd Aug 19, 2026
715d3a5
fix(engine): document what the perspective near plane really bounds
yuto-trd Aug 19, 2026
72014b7
test(engine): pin the Skia chain anchor on a non-origin-anchored input
yuto-trd Aug 19, 2026
f082729
fix(engine): anchor a Skia chain independently of where OriginalBound…
yuto-trd Aug 19, 2026
48318a6
fix(engine)!: fuse a stack of built-in Skia filters, not just a singl…
yuto-trd Aug 19, 2026
904e693
fix(engine): align the transform gizmo against the box the renderer d…
yuto-trd Aug 19, 2026
70479b1
fix(engine)!: apply a pending Skia colour filter exactly once
yuto-trd Aug 19, 2026
3d65b63
test(engine): pin the drawable-brush fit to its fractional content bo…
yuto-trd Aug 19, 2026
4be97a8
test(engine): give the delayed-nested-brush fixture the tests it was …
yuto-trd Aug 19, 2026
a619d80
fix(engine)!: rasterize a custom effect's input on the grid it crops on
yuto-trd Aug 19, 2026
4673e2a
fix(tests): bound the filter-layer poison guard to the apron the laye…
yuto-trd Aug 20, 2026
3c33795
refactor(engine)!: publish one rectangle-bounds map, the one perspect…
yuto-trd Aug 20, 2026
d803801
docs(specs): document the remaining breaking changes and retire the w…
yuto-trd Aug 20, 2026
47bd883
docs(engine): state that a materialized drawable-brush image transfer…
yuto-trd Aug 20, 2026
1ecec31
feat(engine): let a public FilterEffectActivator supply a drawable-br…
yuto-trd Aug 20, 2026
816ee51
fix(engine): stop reinstalling a render node whose disposal already b…
yuto-trd Aug 20, 2026
a79f556
fix(engine): report a nested scene's frame as its query footprint
yuto-trd Aug 20, 2026
5b56d2d
fix(engine): degrade a nested-target allocation failure in preview
yuto-trd Aug 20, 2026
a9bc1af
fix(editor): keep a template preview when its content needs a target …
yuto-trd Aug 20, 2026
8ae8631
fix(engine): invalidate every cached dependent of a changed shared child
yuto-trd Aug 20, 2026
d53b155
fix(engine)!: hold the filter apron a pixel from every sheared edge
yuto-trd Aug 20, 2026
87c7464
feat(engine): let external authors map backward render density
yuto-trd Aug 20, 2026
9918ab9
fix(engine): unwind the render-pass scope when a pass body throws
yuto-trd Aug 20, 2026
120d6bd
fix(engine): keep a synchronous flush from submitting an open render …
yuto-trd Aug 20, 2026
a52b0a5
fix(engine): release the backend texture when a render target fails t…
yuto-trd Aug 20, 2026
96154ed
fix(editor): release a thumbnail resource attached to a stopped rende…
yuto-trd Aug 20, 2026
449e712
fix(engine)!: match the ChromaKey colour where its error lives
yuto-trd Aug 20, 2026
c8314e4
refactor(engine)!: name the supply map that drops backward demand
yuto-trd Aug 20, 2026
6120900
docs(specs): reconcile the 004 specification with what was built
yuto-trd Aug 20, 2026
cef2f8e
fix(engine): keep a dark colour that only its hue separates from the key
yuto-trd Aug 21, 2026
325a7f6
fix(engine): scope a render-cache opt-out to the checkpoint that aske…
yuto-trd Aug 21, 2026
832090d
fix(engine): report an unterminated SkSL block comment instead of thr…
yuto-trd Aug 21, 2026
ef20ce1
test(engine): give the rendering suites the assertions their names pr…
yuto-trd Aug 21, 2026
1498b9f
fix(engine): honour a caller's render-target factory in the legacy fi…
yuto-trd Aug 21, 2026
2dbe81d
fix(engine): close five merge-blocking render defects
yuto-trd Aug 21, 2026
6857dfa
feat(engine)!: let an opaque source declare buffer-only raster room
yuto-trd Aug 21, 2026
518b3e2
fix(engine): keep a degraded preview frame out of everything that out…
yuto-trd Aug 21, 2026
8c79620
perf(engine): stop an output identity costing what its paths enumerate
yuto-trd Aug 21, 2026
73160e0
fix(engine): stop four resources outliving or outrunning what owns them
yuto-trd Aug 22, 2026
1e79834
docs(specs): record the backend contract change and correct four stal…
yuto-trd Aug 22, 2026
9d2fc5c
fix(engine): hand back a contributed value's target as soon as it is …
yuto-trd Aug 22, 2026
2af9acf
docs(specs): record the three author-visible changes the last commits…
yuto-trd Aug 22, 2026
7de1520
feat(engine): let an authored hit test read the resource its call bound
yuto-trd Aug 22, 2026
fa533c3
feat(engine): let a whole-source shader raise the demand on what it s…
yuto-trd Aug 22, 2026
1fa4836
fix(engine): stop hit testing reporting content the request clips away
yuto-trd Aug 22, 2026
9a49126
feat(engine): let a combine declare what density each of its inputs n…
yuto-trd Aug 22, 2026
9316699
fix(engine): keep a reusable renderer's own allocations on the contex…
yuto-trd Aug 22, 2026
bdf088a
fix(engine): invalidate a container's cache when its children change
yuto-trd Aug 22, 2026
253bc4f
fix(engine): stop a target layer scope hitting outside the region it …
yuto-trd Aug 22, 2026
09a76f3
fix(engine): report the content a self-allocating path drops from a p…
yuto-trd Aug 22, 2026
def8dcb
refactor(engine)!: admit that a detached resource has no backing object
yuto-trd Aug 22, 2026
8988a5d
fix(engine): reject the Vulkan work a driver would leave undefined
yuto-trd Aug 22, 2026
7855b18
test(engine): fail non-finite parity pixels on a job that declared a GPU
yuto-trd Aug 22, 2026
db38ca8
feat(engine): let a plugin author parse a shader source once and reus…
yuto-trd Aug 22, 2026
66dc048
refactor(engine)!: make a render host state what its output is for
yuto-trd Aug 22, 2026
294ce95
docs(specs): stop the 004 record contradicting itself, and unpin the …
yuto-trd Aug 22, 2026
7e4d3b0
test(engine): fail a GPU run that reports a Vulkan validation error
yuto-trd Aug 23, 2026
17f301f
fix(engine): keep a render pass's own barriers in recording order
yuto-trd Aug 23, 2026
c9ab893
fix(engine)!: push constants for the stages the bound layout declares
yuto-trd Aug 23, 2026
79d02f0
docs(specs): count the push-constant break in the migration contract
yuto-trd Aug 23, 2026
e20b9d1
fix(engine): tell Skia the layout its image is actually in
yuto-trd Aug 23, 2026
f6285fc
fix(engine): request the cube-array feature point-light shadows depen…
yuto-trd Aug 23, 2026
1ae2195
fix(engine): make every allocated shadow slot readable before anythin…
yuto-trd Aug 23, 2026
6f439b1
fix(engine): make an array texture state the usage its format supports
yuto-trd Aug 23, 2026
45244fe
docs: point the validation-gate exclusion at its tracking issue
yuto-trd Aug 23, 2026
114e26f
fix(engine): release a texture's handles when its initial transition …
yuto-trd Aug 23, 2026
99ad6e1
fix(engine): let a Skia clear settle the backend's transparency record
yuto-trd Aug 23, 2026
2c62494
fix(app): export a selected element whole, not the part the frame shows
yuto-trd Aug 23, 2026
c67dcf5
fix(engine): ask every target scope what density its input needs
yuto-trd Aug 23, 2026
104529d
feat(engine): let a geometry operation say what density its input needs
yuto-trd Aug 23, 2026
5a12139
feat(engine): let a target command say what density each of its input…
yuto-trd Aug 23, 2026
d58ef7c
docs(specs): record where an input-demand contract can now be declared
yuto-trd Aug 23, 2026
5f5fd86
fix(engine): keep an append scope's input at the density its destinat…
yuto-trd Aug 23, 2026
749c944
docs(specs): say where an input-demand contract has no equivalent
yuto-trd Aug 23, 2026
bd69026
fix(engine): flush the context the target being sampled actually live…
yuto-trd Aug 23, 2026
fad57f2
fix(engine): reject a metadata capture the author can still reassign
yuto-trd Aug 23, 2026
f12d459
fix(engine): let a contract be recorded from inside another lambda
yuto-trd Aug 23, 2026
42e3e0e
fix(engine): settle a capture by its declared type instead of reading it
yuto-trd Aug 23, 2026
155cbc3
fix(engine): carry a raw scope's declared demand back to its input
yuto-trd Aug 23, 2026
ea614fe
fix(engine): keep a group's full-target work when it isolates
yuto-trd Aug 23, 2026
2fa6ed4
feat(engine): let a raw session address a resource by its declared slot
yuto-trd Aug 23, 2026
8d02d5e
fix(engine): follow what a captured read-only view points at
yuto-trd Aug 23, 2026
20dfe84
fix(engine): fix an immutable collection by what it holds, not by its…
yuto-trd Aug 23, 2026
5ade9db
feat(engine)!: let a scope declare the space its replay transform liv…
yuto-trd Aug 23, 2026
41206bc
fix(engine): rebuild a nested scene at the density the request asks for
yuto-trd Aug 23, 2026
788d2be
fix(engine): declare a perspective transform's bounds where its outpu…
yuto-trd Aug 23, 2026
d685776
fix(engine): split a render pass around the work Vulkan forbids insid…
yuto-trd Aug 23, 2026
e287003
fix(engine): prepare a node reached with explicit inputs too
yuto-trd Aug 23, 2026
5932544
fix(engine): tell the request when a native replacement was declined
yuto-trd Aug 23, 2026
b4e2366
fix(engine): stop clearing a target its consumer is about to overwrite
yuto-trd Aug 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .claude/skills/beutl-agent-source-grounding/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ If the user explicitly forbids source-code reading, do not use this skill. Recor
| Shape sizing and local drawing | `src/Beutl.Engine/Graphics/Shapes/Shape.cs`, `RectShape.cs`, `RoundedRectShape.cs`, `EllipseShape.cs` | Bounds size, stroke inflation, and draw origin. |
| GeometryShape geometry positioning | `src/Beutl.Engine/Graphics/Shapes/Shape.cs` (`OnDraw`, `MeasureCore`) | The `-shapeBounds.Position` normalization is commented out and `MeasureCore` returns only `geometry.Bounds.Size`, so a path is drawn offset by `geometry.Bounds.Position`. Author paths around `(0,0)` or `measure_object_bounds` + compensate. Closed Pen-only paths render when the `Pen` brush/thickness and path bounds are valid. |
| Transform numeric meaning | `src/Beutl.Engine/Graphics/Transformation/TranslateTransform.cs`, `ScaleTransform.cs`, `TransformGroup.cs`, `CanonicalTransformLayout.cs` | Whether values are absolute positions, offsets, percentages, or ordered transform children. `ScaleTransform` values are percentages (`100` = 1x), not normalized multipliers. |
| Render-node transform and bounds behavior | `src/Beutl.Engine/Graphics/Rendering/TransformRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeProcessor.cs`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeContext.cs` | Operation bounds aggregation, bounds transformation, hit-test inversion, and density rescale. |
| Render-node transform and bounds behavior | `src/Beutl.Engine/Graphics/Rendering/TransformRenderNode.cs`, `src/Beutl.Engine/Graphics/Rendering/RenderNodeContext.cs`, `src/Beutl.Engine/Graphics/Rendering/Planning/RenderRequestExecutor.cs` | Operation bounds aggregation, bounds transformation, hit-test inversion, and density rescale. |
| Toolkit examples and generated snippets | `src/Beutl.AgentToolkit/Schema/SchemaGenerator.cs`, `CompositionTemplates.cs` | How toolkit examples choose translate values, animation discriminators, and reusable object shapes. |
| Quality analyzer assumptions | `src/Beutl.AgentToolkit/Rendering/QualityAnalyzer.cs` | How text/plate bounds, centers, foreground rect dominance, and typography overload are estimated. |
| Still and motion verification | `src/Beutl.AgentToolkit/Rendering/StillRenderer.cs`, `MotionVariationAnalyzer.cs` | Which warnings should block export and how frame coverage is computed. |
Expand Down
60 changes: 59 additions & 1 deletion .claude/skills/beutl-filter-effect/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,65 @@ Create your own resource files inside the extension project, or pass a literal s

### SKSL (SkiaShaderLanguage) pattern

Compile the shader in the static constructor and apply it through `CustomEffect`:
> **Prefer `context.Shader(...)` for per-pixel work.** A `ShaderDefinition<TState>` recorded through
> `FilterEffectContext.Shader` is a typed fragment the planner can fuse with neighbouring shader stages
> into one GPU pass. `SKSLScriptEffect` also records supported scripts declaratively and exposes them to
> the fusion planner: `half4 main(float2 fragCoord)` becomes `WholeSource`, which can head a fusion run
> and absorb later per-pixel work but not upstream operations, while `half4 apply(half4 color)` becomes
> fully fusable `CurrentPixel` work. Scripts
> that cannot be represented declaratively—including reserved `__beutl*`/`fe*_*` names, multi-declarator
> uniforms, non-literal array lengths, or uniform types without a canonical zero value—automatically fall
> back to the legacy `CustomEffect` path, so existing scripts do not break. Using `CustomEffect`
> directly remains the right tool for raw-target allocation, sampling, or drawing, but it is opaque to the
> planner and forms a fusion boundary.

| Authoring construct | Fusion behavior | Limit or boundary |
|---|---|---|
| `CurrentPixel` shaders; immutable opacity | Fusable | May join a compatible fusion run. |
| `WholeSource` shaders | Can be the head of a fusion run | May absorb later per-pixel work; upstream work cannot fold into it. |
| Skia image filters (`Blur`, `DropShadow`, `Dilate`, `Erode`); `CustomEffect`; Geometry; 3D; raw canvas access | Not fusable | Forms a fusion boundary. |
| Sampler/child budget | Portable: 12; Vulkan/Metal: 12 | The implicit `src` sampler consumes one slot; exceeding the cap falls back to a standalone pass. |

Declare the shader once as a `static readonly ShaderDefinition<TState>` and record a call of it per frame.
The definition holds the shape — source, uniform and resource bindings — and `.Call(state)` supplies this
frame's values, so the planner sees a typed fragment it can fuse:

```csharp
public partial class MosaicEffect : FilterEffect
{
private static readonly ShaderDefinition<Size> s_definition =
ShaderDefinition<Size>.WholeSource(
"""
uniform shader src;
uniform float2 tileSize;

half4 main(float2 fragCoord) {
float2 blockIndex = floor(fragCoord / tileSize);
float2 sampleCoord = blockIndex * tileSize + tileSize * 0.5;
return src.eval(sampleCoord);
}
""",
RenderBoundsContract.Identity,
static bindings => bindings.Uniform("tileSize", static tileSize => tileSize.ToVector2()));

public override void ApplyTo(FilterEffectContext context, FilterEffect.Resource resource)
{
var r = (Resource)resource;
context.Shader(s_definition.Call(r.TileSize));
}
}
```

The definition callback must be pure and non-capturing: its `MethodInfo` is the shader's structural
identity, so two frames that differ only in `tileSize` reuse one compiled program. If several effects share
one source, parse it once with `SkslSource.WholeSource(...)` or `SkslSource.CurrentPixel(...)` and pass the
result to the matching factory instead of the raw string.

### The `CustomEffect` fallback

Reach for this only when the work cannot be expressed declaratively — raw target allocation, sampling
outside the declared input, or drawing onto the target. It is opaque to the planner and forms a fusion
boundary. The same mosaic, written the imperative way:

```csharp
public partial class MosaicEffect : FilterEffect
Expand Down
30 changes: 29 additions & 1 deletion .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: .NET
name: .NET

on:
push:
Expand Down Expand Up @@ -52,6 +52,34 @@ jobs:
# SwiftShader Vulkan ICD fails to load; see GpuGoldenSuiteCanaryTests.
BEUTL_REQUIRE_GPU: "1"

# A validation error names API misuse the driver is not required to diagnose - a render pass instance
# begun inside another, a handle submitted to a device that never created it - so a suite that runs
# green while reporting one has already entered undefined behaviour. The layer stays off for the run
# above because it costs time on all 7,000 tests and is only meaningful for the GPU-backed ones; this
# step turns it on for exactly those. VulkanTestEnvironment/GpuTestEnvironment compare the validation
# log around every render-thread invocation, so an error fails the test that reported it, and
# VulkanValidationGateTests fails if the layer was requested but did not load - a gate that cannot
# observe anything must not pass quietly.
- name: Install Vulkan validation layers
Comment thread
yuto-trd marked this conversation as resolved.
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends vulkan-validationlayers

# KnownVulkanSkiaLayoutInterop is excluded: Skia and the backend track the same image's layout
# independently and drift apart, which validation reports as InvalidImageLayout. Closing it needs a
# way to read back or command the layout Skia holds, which SkiaSharp 3.119 does not expose. Those
# tests still run in the step above; see TestCategories.KnownVulkanSkiaLayoutInterop and issue #2263,
# on whose close both TestCategory!= clauses below come out.
- name: GPU tests under Vulkan validation
run: |
dotnet test tests/Beutl.UnitTests/Beutl.UnitTests.csproj --no-build -f net10.0 \
--filter "(TestCategory=GpuPassFusionGpu|FullyQualifiedName~VulkanValidationGateTests|FullyQualifiedName~GpuGoldenSuiteCanaryTests)&TestCategory!=KnownVulkanSkiaLayoutInterop"
dotnet test tests/Beutl.Graphics3DTests/Beutl.Graphics3DTests.csproj --no-build -f net10.0 \
--filter "TestCategory!=KnownVulkanSkiaLayoutInterop"
env:
BEUTL_REQUIRE_GPU: "1"
BEUTL_VULKAN_VALIDATION: "1"

- name: Merge coverage reports
# Every test project now collects coverage (coverlet.collector is shared via
# tests/Directory.Build.props), so dotnet test emits one cobertura file per test
Expand Down
2 changes: 1 addition & 1 deletion .specify/memory/constitution.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Beutl targets `net10.0` and `net10.0-windows`. Both targets must keep building.

### III. Test-First with NUnit

- Test framework is NUnit + Moq. Tests are organized under `tests/` in per-area projects (e.g. `tests/Beutl.UnitTests/`, `tests/Beutl.Graphics3DTests/`, `tests/SourceGeneratorTest/`, `tests/Beutl.FFmpegIpc.Tests/`). `tests/Beutl.Graphics3DTests/` is a Vulkan-gated NUnit suite that self-skips when no Vulkan device is available.
- Test framework is NUnit + Moq. Tests are organized under `tests/` in per-area projects (e.g. `tests/Beutl.UnitTests/`, `tests/Beutl.PublicApiContractTests/`, `tests/Beutl.Graphics3DTests/`, `tests/SourceGeneratorTest/`, `tests/Beutl.FFmpegIpc.Tests/`). `tests/Beutl.PublicApiContractTests/` is the non-friend compile gate for public authoring APIs; `tests/Beutl.Graphics3DTests/` is a Vulkan-gated NUnit suite that self-skips when no Vulkan device is available.
- New logic in `src/` is incomplete without an accompanying test.
- Benchmarks (`tests/Beutl.Benchmarks`, `tests/Beutl.FFmpegBenchmarks`) use BenchmarkDotNet and are excluded from regular `dotnet test` runs.
- CI quality gate: `dotnet test Beutl.slnx -f net10.0 --settings coverlet.runsettings` must pass, with the coverage threshold configured in [`.github/workflows/dotnet.yml`](../../.github/workflows/dotnet.yml) honored.
Expand Down
48 changes: 31 additions & 17 deletions .specify/scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -264,29 +264,43 @@ get_feature_paths() {

# Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
# 3. Branch-name-based prefix lookup (legacy fallback)
local feature_dir
# 2. Branch-name prefix lookup, when it names a feature directory that exists
# 3. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
#
# The branch outranks the pin because feature.json is a single checked-in value shared by every
# feature in the repo: whichever feature wrote it last would otherwise redirect the analysis,
# planning and task scripts on every other feature's branch to its own directory, silently.
# The pin still resolves a branch whose name says nothing about which feature it belongs to,
# which is the case it was added for.
local feature_dir=''
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
# Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
# Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on
# missing/unparseable/unset so we fall through to the branch-prefix lookup.
local _fd
_fd=$(read_feature_json_feature_directory "$repo_root")
if [[ -n "$_fd" ]]; then
feature_dir="$_fd"
# Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
echo "ERROR: Failed to resolve feature directory" >&2
else
local _branch_dir=''
if ! _branch_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
return 1
fi
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
echo "ERROR: Failed to resolve feature directory" >&2
return 1

if [[ -d "$_branch_dir" ]]; then
feature_dir="$_branch_dir"
else
# Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on
# missing/unparseable/unset so we fall back to whatever the branch named.
local _fd
_fd=$(read_feature_json_feature_directory "$repo_root")
if [[ -n "$_fd" ]]; then
feature_dir="$_fd"
# Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
elif [[ -n "$_branch_dir" ]]; then
feature_dir="$_branch_dir"
else
echo "ERROR: Failed to resolve feature directory" >&2
return 1
fi
fi
fi

# Use printf '%q' to safely quote values, preventing shell injection
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Beutl is a cross-platform video editing / compositing application built on Avalo

- License: the main app is **MIT**; `Beutl.FFmpegWorker` alone is **GPL-3.0-or-later** (a separate process)
- UI: Avalonia (XAML + ViewModel)
- Tests: NUnit + Moq under `tests/` (per-area projects, e.g. `tests/Beutl.UnitTests/`, `tests/Beutl.Graphics3DTests/`, `tests/SourceGeneratorTest/`, `tests/Beutl.FFmpegIpc.Tests/`). `tests/Beutl.Graphics3DTests/` is a Vulkan-gated NUnit suite that self-skips when no Vulkan device is available — see `tests/CLAUDE.md`
- Tests: NUnit + Moq under `tests/` (per-area projects, e.g. `tests/Beutl.UnitTests/`, `tests/Beutl.PublicApiContractTests/`, `tests/Beutl.Graphics3DTests/`, `tests/SourceGeneratorTest/`, `tests/Beutl.FFmpegIpc.Tests/`). `tests/Beutl.PublicApiContractTests/` is the non-friend compile gate for public authoring APIs; `tests/Beutl.Graphics3DTests/` is a Vulkan-gated NUnit suite that self-skips when no Vulkan device is available — see `tests/CLAUDE.md`
- E2E / headless-UI tests: `tests/Beutl.E2ETests/` (library-level) and `tests/Beutl.HeadlessUITests/` (drives the real shell, the sole test referencing `src/Beutl`) on `Avalonia.Headless.NUnit`, with shared helpers in `tests/Beutl.Testing.Headless/`. They run on headless CI without xvfb or a GPU — see `tests/CLAUDE.md`
- Build: Nuke (`nukebuild/`) or `dotnet` directly

Expand Down
1 change: 1 addition & 0 deletions Beutl.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<Project Path="tests\Beutl.UnitTests\Beutl.UnitTests.csproj" />
<Project Path="tests\Beutl.E2ETests\Beutl.E2ETests.csproj" />
<Project Path="tests\Beutl.HeadlessUITests\Beutl.HeadlessUITests.csproj" />
<Project Path="tests\Beutl.PublicApiContractTests\Beutl.PublicApiContractTests.csproj" />
<Project Path="tests\Beutl.Testing.Headless\Beutl.Testing.Headless.csproj" />
<Project Path="tests\SourceGeneratorTest\SourceGeneratorTest.csproj" />
<File Path="tests\ArtifactProvider.cs" />
Expand Down
8 changes: 6 additions & 2 deletions docs/ai-workflow/resolution-independent-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ filter effects, brushes, and shaders.
| Scale | Type | Meaning |
|---|---|---|
| **Output scale `s_out`** | `Renderer.OutputScale` / `RenderNodeContext.OutputScale` | the final target only: device pixels per logical unit at the root. `1.0` = logical == device. |
| **Effective scale** | `RenderNodeOperation.EffectiveScale` | the supply density an op's pixels actually exist at. Vector ops are `Unbounded`; bitmap ops report `At(scale)`. |
| **Effective scale** | `RenderFragmentHandle.EffectiveScale` (also on `RenderFragmentMetadata`) | the supply density a recorded fragment's pixels actually exist at. Vector fragments are `Unbounded`; bitmap fragments report `At(scale)`. |
| **Working scale `w`** | `FilterEffectContext.WorkingScale` (+ `RenderNodeContext.ResolveWorkingScale`) | the density a buffer-allocating boundary runs at, negotiated from the inputs' supply densities (falling back to `s_out` for vector-only inputs), capped by `MaxWorkingScale`. There is no per-effect policy knob. |

## What most authors need to do: nothing
Expand Down Expand Up @@ -52,7 +52,11 @@ the CTM handles it, and a manual `× w` would double-scale and regress the resul
**no `ResolutionPolicy`**: the earlier `Inherit`/`ClampToOutput`/`Oversample(k)`/`PreserveSource` policy
was removed because no built-in needed a non-default value. An effect that genuinely needs a different
working scale (clamp-to-output for perf, oversample for SSAA) returns a `FilterEffectRenderNode` subclass from
`FilterEffect.Resource.CreateRenderNode()` and overrides `Process` to compute its own `w`.
`FilterEffect.Resource.CreateRenderNode()` and overrides `GetWorkingScaleContract()` to return a
`RenderScaleContract`. Overriding `Process` is for an effect that needs different topology or lowering, not
merely a different density; see
[`effect-scale-contract.md`](../specs/003-resolution-independent-pipeline/contracts/effect-scale-contract.md)
for a worked example.
- **Bitmap sources.** A decoded image/video op reports its decoded density as `EffectiveScale.At(...)`,
distinct from its logical footprint. Mixed-scale compositing resamples off-target bitmaps via
`ImmediateCanvas.DrawRenderTargetScaled` / `DrawSurfaceScaled` (Mitchell). 003 ships only this seam; the
Expand Down
Loading
Loading