Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package vapoursynth for openSUSE:Factory checked in at 2026-11-02 16:41:22 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/vapoursynth (Old) and /work/SRC/openSUSE:Factory/.vapoursynth.new.1255 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "vapoursynth" Mon Nov 2 16:41:22 2026 rev:40 rq:1394470 version:82 Changes: -------- --- /work/SRC/openSUSE:Factory/vapoursynth/vapoursynth.changes 2026-10-05 16:19:06.399476907 +0200 +++ /work/SRC/openSUSE:Factory/.vapoursynth.new.1255/vapoursynth.changes 2026-11-02 16:41:54.820115698 +0100 @@ -1,0 +2,20 @@ +Mon Nov 2 13:25:43 UTC 2026 - Martin Pluskal <[email protected]> + +- Update to R82: + * vspipe matroska output is now playable by mpv and sets + track properties from the first frame values + * vspipe no longer downloads GPU resident frames when there + is no output + * Enable VK_KHR_pipeline_executable_properties when available + for GPU filter pipeline inspection + * Fix GPU filters running at PCIe speed on NVIDIA cards with + resizable BAR: exportable GPU memory stays out of host + visible VRAM on discrete cards + * Fix plugins using vs_use_api_43 getting the deprecated + full/limited color range constants swapped +- No ABI or soname change: 82 is the release counter, not the + API level, so out-of-tree plugins stay compatible +- Use pkgconfig(python3) and pkgconfig(python3-embed) instead + of python3-devel in BuildRequires + +------------------------------------------------------------------- Old: ---- vapoursynth-R81.tar.gz New: ---- vapoursynth-R82.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ vapoursynth.spec ++++++ --- /var/tmp/diff_new_pack.3REfkD/_old 2026-11-02 16:41:55.626149325 +0100 +++ /var/tmp/diff_new_pack.3REfkD/_new 2026-11-02 16:41:55.628149408 +0100 @@ -18,7 +18,7 @@ %define sover 4 Name: vapoursynth -Version: 81 +Version: 82 Release: 0 Summary: A video processing framework # upstream's meson.build declares license: 'LGPL-2.1-or-later' and @@ -36,9 +36,6 @@ BuildRequires: pkgconfig BuildRequires: python-rpm-macros BuildRequires: python3-Cython -# spec-cleaner --pkgconfig rewrites this to pkgconfig(python-3.6) + -# pkgconfig(python-3.6m), stale hardcoded numbers; no such .pc exists -BuildRequires: python3-devel BuildRequires: pkgconfig(Magick++) >= 7.0 BuildRequires: pkgconfig(libarchive) BuildRequires: pkgconfig(libass) @@ -46,6 +43,7 @@ BuildRequires: pkgconfig(libavformat) BuildRequires: pkgconfig(libavutil) BuildRequires: pkgconfig(python3) +BuildRequires: pkgconfig(python3-embed) BuildRequires: pkgconfig(tesseract) # R80 GPU path: headers only at build time (loader is dlopened); glslang C API # for runtime GLSL->SPIR-V. Wrap-git subprojects cannot fetch in OBS. ++++++ vapoursynth-R81.tar.gz -> vapoursynth-R82.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/ChangeLog new/vapoursynth-R82/ChangeLog --- old/vapoursynth-R81/ChangeLog 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/ChangeLog 2026-11-01 20:02:33.000000000 +0100 @@ -1,3 +1,11 @@ +r82: +vspipe's matroska output is now playable by mpv as well +vspipe's matroska output now sets track properties based on the first frame values of _Matrix, _Transfer, _Primaries, _Range, _ChromaLocation, _FieldBased, _SARNum and _SARDen +vspipe no longer downloads gpu resident frames when there's no output +vk_khr_pipeline_executable_properties is now enabled when available so gpu filter authors can inspect their compiled pipelines +fixed gpu filters running at pcie speed on nvidia cards with resizable bar exposed, exportable gpu memory is never placed in host visible vram on discrete cards anymore +fixed plugins defining vs_use_api_43 getting the deprecated vscolorrange constants with full and limited swapped + r81: improved gpu up/download speed fixed memory model for gpu filters that export memory to cuda/vulkan, input frames they take are copied at most once per source frame and shared between requests diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/VAPOURSYNTH_VERSION new/vapoursynth-R82/VAPOURSYNTH_VERSION --- old/vapoursynth-R81/VAPOURSYNTH_VERSION 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/VAPOURSYNTH_VERSION 2026-11-01 20:02:33.000000000 +0100 @@ -1 +1 @@ -#define VS_CURRENT_RELEASE 81 +#define VS_CURRENT_RELEASE 82 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/doc/api/vsvulkan4.h.rst new/vapoursynth-R82/doc/api/vsvulkan4.h.rst --- old/vapoursynth-R81/doc/api/vsvulkan4.h.rst 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/doc/api/vsvulkan4.h.rst 2026-11-01 20:02:33.000000000 +0100 @@ -1534,9 +1534,10 @@ compute queue. That copy is made once per frame and shared by every request of it, from any filter, for as long as the frame stays cached, so a temporal filter pays one copy per source frame rather than one per tap. A - frame std.GPUUpload made is always copied where exportable memory cannot be - host visible (AMD's Windows driver): its planes are left unexportable - there, so the upload can write them directly. + frame std.GPUUpload made is always copied where exportable memory is not + host visible (discrete cards with resizable BAR, and AMD's Windows driver): + its planes are left unexportable there, so the upload can write them + directly. Like any input frame it is read only, on the foreign side as much as on the core's queues, and it never goes back to the core: returning it, or a frame diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/doc/gpufilters.rst new/vapoursynth-R82/doc/gpufilters.rst --- old/vapoursynth-R81/doc/gpufilters.rst 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/doc/gpufilters.rst 2026-11-01 20:02:33.000000000 +0100 @@ -67,9 +67,10 @@ A script may leave its outputs GPU resident: ``set_output`` stores the node as given, and the consumer decides where the download goes — ``output`` inserts one, and so do the VSScript entry points vspipe and most applications use, -each with a log message, so scripts just work. A clip and its alpha must share -a residency, which is the one thing ``set_output`` checks. Reading pixel data -of a GPU resident *frame* from +each with a log message, so scripts just work. vspipe skips it when it writes +no output (``--``), since nothing reads the pixels then. A clip and its alpha +must share a residency, which is the one thing ``set_output`` checks. Reading +pixel data of a GPU resident *frame* from Python raises an error — pass the clip through GPUDownload first; whether a node or frame is GPU resident is exposed as the ``gpu_resident`` property. Frame properties are always CPU side and work normally on GPU frames. (In the @@ -498,12 +499,12 @@ frame stays cached, so a temporal filter's taps cost one copy per frame, not one per tap. Either way the frame is read only, like every input, and it never goes back to the core: a frame you return must not be it, share - its planes or hold it in its properties. On AMD's Windows driver, whose - exportable memory cannot be host visible, a frame std.GPUUpload made is - always copied: its planes are left unexportable so the upload can write - them directly. Frames in its properties are not handed over: request an - *_Alpha* frame as a clip of its own (std.PropToClip). Never call it while - holding an exec context. + its planes or hold it in its properties. Where exportable memory is not + host visible — discrete cards with resizable BAR, and AMD's Windows + driver — a frame std.GPUUpload made is always copied: its planes are left + unexportable so the upload can write them directly. Frames in its + properties are not handed over: request an *_Alpha* frame as a clip of its + own (std.PropToClip). Never call it while holding an exec context. #. Export each plane you touch. Cache imports keyed by *memoryId* — one ``cudaImportExternalMemory`` per 128 MB allocation, then per-plane pointers are just base + offset. Close surplus handles per the ownership @@ -620,14 +621,22 @@ lives in the VSVulkan4.h header. A few capabilities are optional and must be queried on the physical device: ``shaderFloat16`` (half precision arithmetic), ``shaderInt64`` and ``shaderFloat64`` (64-bit arithmetic), the 64-bit integer -atomics, and the float atomics of VK_EXT_shader_atomic_float and its float2 -companion. The core enables each one when the device has it, but none is -promised and none is reported back, so a kernel that wants one asks for itself. -Everything else that is optional in Vulkan is simply absent: beyond the atomic -float pair, the only device extensions a core device enables are the platform's -opaque handle export pair and, where the device demands it, +atomics, and the float atomics of VK_EXT_shader_atomic_float and +VK_EXT_shader_atomic_float2. The core enables each one when the device has it, +but none is promised and none is reported back, so a kernel that wants one asks +for itself. The two float atomic extensions are independent of each other: a +float32 atomic add needs only VK_EXT_shader_atomic_float and its +``shaderBufferFloat32AtomicAdd`` bit, whether or not the device offers float2. +Everything else that is optional in Vulkan is simply absent: beyond the float +atomic extensions, the only device extensions a core device enables are +VK_KHR_pipeline_executable_properties where the device offers it, the +platform's opaque handle export pair and, where the device demands it, VK_KHR_portability_subset — none of them reachable from a kernel, so vendor -specific paths cannot exist on them. +specific paths cannot exist on them. The first is there for tuning kernels: +create a pipeline with ``VK_PIPELINE_CREATE_CAPTURE_STATISTICS_BIT_KHR`` and +vkGetPipelineExecutableStatisticsKHR reports what it uses, registers and local +memory among it. Enabling the extension costs nothing, and its capture flags +never change the compiled code. Porting an existing Vulkan filter --------------------------------- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/doc/output.rst new/vapoursynth-R82/doc/output.rst --- old/vapoursynth-R81/doc/output.rst 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/doc/output.rst 2026-11-01 20:02:33.000000000 +0100 @@ -15,7 +15,9 @@ If *outfile* is a hyphen (``-``), vspipe will write to the standard output. If *outfile* is a double hyphen (``--``), vspipe will do everything as usual, except it -will not write the video frames anywhere. +will not write the video frames anywhere. Since nothing reads their pixels then, GPU +resident outputs are not downloaded either, so a benchmark measures the script alone. +``--timecodes`` and ``--json`` still work, as they only read frame properties. Options @@ -115,6 +117,14 @@ and its timeline is accumulated from the ``_DurationNum`` and ``_DurationDen`` frame properties, which must then be present on every frame. +Each video track's header also carries what the reserved frame properties of its first frame say +about it: ``_Matrix``, ``_Transfer``, ``_Primaries``, ``_Range``, ``_ChromaLocation`` for subsampled +YUV, ``_FieldBased``, and the pixel aspect ratio from ``_SARNum`` and ``_SARDen``. Readers such as +ffmpeg and mpv pick these up, so an encode or a preview gets them right without repeating them on +a command line. Properties that are absent, unspecified or have no Matroska equivalent are left +out. A header describes its whole track and cannot change partway through, so a later frame whose +properties differ from the first frame's is still written, with a warning. + Supported formats ----------------- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/include/VSConstants4.h new/vapoursynth-R82/include/VSConstants4.h --- old/vapoursynth-R81/include/VSConstants4.h 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/include/VSConstants4.h 2026-11-01 20:02:33.000000000 +0100 @@ -21,7 +21,8 @@ #ifndef VSCONSTANTS4_H #define VSCONSTANTS4_H -#if defined(VS_USE_LATEST_API) || defined(VS_USE_API_42) +/* _Range replaced _ColorRange in API 4.2, so every API version from there on needs listing here. */ +#if defined(VS_USE_LATEST_API) || defined(VS_USE_API_43) || defined(VS_USE_API_42) typedef enum VSRange { VSC_RANGE_FULL = 1, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/include/VSVulkan4.h new/vapoursynth-R82/include/VSVulkan4.h --- old/vapoursynth-R81/include/VSVulkan4.h 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/include/VSVulkan4.h 2026-11-01 20:02:33.000000000 +0100 @@ -301,10 +301,10 @@ /* The device feature baseline. The core creates every device itself: Vulkan 1.4 with exactly * the features below, and no extensions but the platform's opaque memory and semaphore handle * export (VK_KHR_external_memory/_semaphore_win32 or _fd) where available, - * VK_KHR_portability_subset where the device demands it and the atomic float pair described at - * the end of this comment. Every REQUIRED entry is mandatory for a conformant 1.4 - * implementation. Sharing frames with another device or API goes through exportGPUPlane, not - * device sharing. + * VK_KHR_portability_subset where the device demands it, and the two float atomic extensions and + * VK_KHR_pipeline_executable_properties described at the end of this comment. Every REQUIRED + * entry is mandatory for a conformant 1.4 implementation. Sharing frames with another device or + * API goes through exportGPUPlane, not device sharing. * * required (VkPhysicalDeviceFeatures): shaderInt16, shaderImageGatherExtended, * shaderStorageImageExtendedFormats, shaderUniformBufferArrayDynamicIndexing, @@ -326,12 +326,18 @@ * shaderFloat64 (Features), shaderBufferInt64Atomics and shaderSharedInt64Atomics * (Vulkan12Features) -- query the physical device to find out whether you got them * - * One extension pair is enabled beyond the handle export pair, under the same policy the - * optional features above follow: whenever the device offers VK_EXT_shader_atomic_float and its - * float2 companion they are enabled, with exactly the feature bits the device reports. That - * guarantee is the availability contract -- Vulkan cannot ask a created device what was enabled, - * so enable-whatever-is-reported is what makes the physical device's own queries authoritative. - * Check presence with vkEnumerateDeviceExtensionProperties and the bits with + * Three more extensions are enabled beyond the handle export pair, under the same policy the + * optional features above follow: VK_EXT_shader_atomic_float whenever the device offers it, and + * VK_EXT_shader_atomic_float2 in addition whenever it offers that too, each with exactly the + * feature bits the device reports. The two are independent: the first never waits for the + * second, so shaderBufferFloat32AtomicAdd, say, needs only VK_EXT_shader_atomic_float and that + * bit. VK_KHR_pipeline_executable_properties is enabled whenever offered as well, for tuning + * kernels: create a pipeline with VK_PIPELINE_CREATE_CAPTURE_STATISTICS_BIT_KHR and read its + * register and memory use back with vkGetPipelineExecutableStatisticsKHR. Enabling it costs + * nothing, and its capture flags cost compile time at most, never changing the compiled code. + * That guarantee is the availability contract -- Vulkan cannot ask a created device what + * was enabled, so enable-whatever-is-reported is what makes the physical device's own queries + * authoritative. Check presence with vkEnumerateDeviceExtensionProperties and the bits with * vkGetPhysicalDeviceFeatures2; what they report is what is live. Resolve any extension entry * points you need yourself through the handles' getInstanceProcAddr -- the function table below * stays core 1.4 plus debug utils by design. */ @@ -994,8 +1000,8 @@ copy is made once per frame and shared by every request of it, from any filter, for as long as the frame stays cached, so a temporal filter pays one copy per source frame rather than one per tap. A frame std.GPUUpload made is always copied where exportable - memory cannot be host visible (AMD's Windows driver): its planes are left unexportable - there, so the upload can write them directly. + memory is not host visible (discrete cards with resizable BAR, and AMD's Windows driver): + its planes are left unexportable there, so the upload can write them directly. Like any input frame it is read only, on the foreign side as much as on the core's queues, and it never goes back to the core: returning it, or a frame that shares its diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/meson.build new/vapoursynth-R82/meson.build --- old/vapoursynth-R81/meson.build 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/meson.build 2026-11-01 20:02:33.000000000 +0100 @@ -3,7 +3,7 @@ license: 'LGPL-2.1-or-later', license_files: 'COPYING.LESSER', meson_version: '>=1.3.0', - version: '81', + version: '82', ) enable_guard_pattern = get_option('enable_guard_pattern') diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/sdk/gpu_cuda_invert_example.cu new/vapoursynth-R82/sdk/gpu_cuda_invert_example.cu --- old/vapoursynth-R81/sdk/gpu_cuda_invert_example.cu 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/sdk/gpu_cuda_invert_example.cu 2026-11-01 20:02:33.000000000 +0100 @@ -12,7 +12,8 @@ * - only planes handed to CUDA can be exported: the output is a fresh frame, handed over by * its first export and taken back by the core when it is returned, and the input is taken * with getExportableFrameFilter, which hands it over with its contents (in place when this -* filter is its only consumer, which the rpStrictSpatial request below arranges) +* filter is its only consumer, which the rpStrictSpatial request below arranges, unless the +* input is std.GPUUpload's on a card with resizable BAR, which is always copied) * - synchronization is device side when possible: input producer pairs are imported with * cudaImportExternalSemaphore (cached by VkSemaphore value, which the producer pair * contract keeps stable for this instance's lifetime) and waited IN THE STREAM; the diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/src/core/vscore.cpp new/vapoursynth-R82/src/core/vscore.cpp --- old/vapoursynth-R81/src/core/vscore.cpp 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/src/core/vscore.cpp 2026-11-01 20:02:33.000000000 +0100 @@ -1817,8 +1817,8 @@ dev->setComputeQueueLimit(1); if (std::getenv("VS_VULKAN_NO_TRANSFER_QUEUE")) dev->setIgnoreTransferFamily(true); - /* Upload targets exportable even where that makes the upload stage, as before, so direct - uploads can be measured against staged ones on the driver where the two differ. */ + /* Upload targets exportable even where that makes the upload stage, so direct uploads can + be measured against staged ones on the devices where the two differ. */ if (std::getenv("VS_VULKAN_EXPORTABLE_UPLOADS")) dev->setExportableUploads(true); /* Validation is a development switch, so an environment variable rather than API surface. @@ -1932,8 +1932,8 @@ std::to_string(memory->limit() >> 20) + " MB host limit (combined ceiling " + std::to_string(memory->combined_limit() >> 20) + " MB)"; /* Said out loud because it decides a transfer path and cannot be seen from Python. Uploads - write planes directly wherever the planes can be host visible, which on a driver that - narrows export means upload targets that are not exportable; the staging left is BAR + write planes directly wherever the planes can be host visible, which takes upload targets + that are not exportable wherever export narrows host visibility; the staging left is BAR memory on a discrete card, host memory otherwise. */ const VSVulkanDevice *created = vulkanDev.load(); if (created->hasResizableBar()) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/src/core/vsvulkan.cpp new/vapoursynth-R82/src/core/vsvulkan.cpp --- old/vapoursynth-R81/src/core/vsvulkan.cpp 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/src/core/vsvulkan.cpp 2026-11-01 20:02:33.000000000 +0100 @@ -776,8 +776,9 @@ paths nothing uses. Optional features are enabled when the device has them, which is what the earlier suitability check deliberately did not insist on. Device extensions follow the same philosophy: none are ever load-bearing. The ones enabled at all are the - platform's opaque handle export pair when export is possible, and the float atomic - pair below when the device offers it. */ + platform's opaque handle export pair when export is possible, and the two float atomic + extensions and pipeline executable properties below, each whenever the device offers + it. */ VSVulkanFeatureChain queried; vk.vkGetPhysicalDeviceFeatures2(physicalDeviceHandle, &queried.f2); VSVulkanFeatureChain enabled; @@ -786,59 +787,56 @@ VS_VK_FEATURE_LIST(VS_VK_ENABLE_FEATURE) #undef VS_VK_ENABLE_FEATURE - /* The float atomic pair, enabled whenever present with exactly the feature bits the device + /* VK_EXT_shader_atomic_float whenever the device offers it, and VK_EXT_shader_atomic_float2 + in addition whenever it offers that too, each with exactly the feature bits the device reports: pure SPIR-V capability unlocks with no cost to code that never uses them, and - enabling exactly what is reported is what keeps the physical device's own feature query - authoritative for what a kernel may declare. - float2 requires the base extension, hence the nesting. Both predate every header this - project can build with, so unlike maintenance1 there is no version concern anywhere. */ + enabling exactly what is reported is what keeps the physical device's own queries + authoritative for what a kernel may declare. The first never waits for the second, which + some devices do not offer at all. float2 requires the first, which every device offering + float2 offers too, hence the nesting. Both predate every header this project can build + with, so unlike maintenance1 there is no version concern anywhere. */ VkPhysicalDeviceShaderAtomicFloatFeaturesEXT atomicFloatEnable = {}; atomicFloatEnable.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SHADER_ATOMIC_FLOAT_FEATURES_EXT; VkPhysicalDeviceShaderAtomicFloat2FeaturesEXT atomicFloat2Enable = {}; atomicFloat2Enable.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SHADER_ATOMIC_FLOAT_2_FEATURES_EXT; - bool atomicFloatEnabled = false; - bool atomicFloat2Enabled = false; - if (deviceExtensionAvailable(vk, physicalDeviceHandle, VK_EXT_SHADER_ATOMIC_FLOAT_EXTENSION_NAME)) { - VkPhysicalDeviceShaderAtomicFloatFeaturesEXT atomicFloatQuery = {}; - atomicFloatQuery.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SHADER_ATOMIC_FLOAT_FEATURES_EXT; - VkPhysicalDeviceShaderAtomicFloat2FeaturesEXT atomicFloat2Query = {}; - atomicFloat2Query.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SHADER_ATOMIC_FLOAT_2_FEATURES_EXT; - const bool atomicFloat2Available = deviceExtensionAvailable(vk, physicalDeviceHandle, - VK_EXT_SHADER_ATOMIC_FLOAT_2_EXTENSION_NAME); - if (atomicFloat2Available) - atomicFloatQuery.pNext = &atomicFloat2Query; + const bool atomicFloatEnabled = + deviceExtensionAvailable(vk, physicalDeviceHandle, VK_EXT_SHADER_ATOMIC_FLOAT_EXTENSION_NAME); + const bool atomicFloat2Enabled = atomicFloatEnabled && + deviceExtensionAvailable(vk, physicalDeviceHandle, VK_EXT_SHADER_ATOMIC_FLOAT_2_EXTENSION_NAME); + if (atomicFloatEnabled) { + if (atomicFloat2Enabled) + atomicFloatEnable.pNext = &atomicFloat2Enable; VkPhysicalDeviceFeatures2 atomicFloatFeatures = {}; atomicFloatFeatures.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2; - atomicFloatFeatures.pNext = &atomicFloatQuery; + atomicFloatFeatures.pNext = &atomicFloatEnable; vk.vkGetPhysicalDeviceFeatures2(physicalDeviceHandle, &atomicFloatFeatures); - - atomicFloatEnable = atomicFloatQuery; - atomicFloatEnable.pNext = nullptr; - atomicFloatEnabled = - atomicFloatEnable.shaderBufferFloat32Atomics || atomicFloatEnable.shaderBufferFloat32AtomicAdd || - atomicFloatEnable.shaderBufferFloat64Atomics || atomicFloatEnable.shaderBufferFloat64AtomicAdd || - atomicFloatEnable.shaderSharedFloat32Atomics || atomicFloatEnable.shaderSharedFloat32AtomicAdd || - atomicFloatEnable.shaderSharedFloat64Atomics || atomicFloatEnable.shaderSharedFloat64AtomicAdd || - atomicFloatEnable.shaderImageFloat32Atomics || atomicFloatEnable.shaderImageFloat32AtomicAdd || - atomicFloatEnable.sparseImageFloat32Atomics || atomicFloatEnable.sparseImageFloat32AtomicAdd; - if (atomicFloatEnabled) { + /* Queried in place, so the structures hold exactly the reported bits; the chain they + form goes in front of the rest of the enable chain whole. */ + if (atomicFloat2Enabled) + atomicFloat2Enable.pNext = enabled.f14.pNext; + else atomicFloatEnable.pNext = enabled.f14.pNext; - enabled.f14.pNext = &atomicFloatEnable; + enabled.f14.pNext = &atomicFloatEnable; + } - atomicFloat2Enable = atomicFloat2Query; - atomicFloat2Enable.pNext = nullptr; - atomicFloat2Enabled = atomicFloat2Available && - (atomicFloat2Enable.shaderBufferFloat16Atomics || atomicFloat2Enable.shaderBufferFloat16AtomicAdd || - atomicFloat2Enable.shaderBufferFloat16AtomicMinMax || atomicFloat2Enable.shaderBufferFloat32AtomicMinMax || - atomicFloat2Enable.shaderBufferFloat64AtomicMinMax || atomicFloat2Enable.shaderSharedFloat16Atomics || - atomicFloat2Enable.shaderSharedFloat16AtomicAdd || atomicFloat2Enable.shaderSharedFloat16AtomicMinMax || - atomicFloat2Enable.shaderSharedFloat32AtomicMinMax || atomicFloat2Enable.shaderSharedFloat64AtomicMinMax || - atomicFloat2Enable.shaderImageFloat32AtomicMinMax || atomicFloat2Enable.sparseImageFloat32AtomicMinMax); - if (atomicFloat2Enabled) { - atomicFloat2Enable.pNext = atomicFloatEnable.pNext; - atomicFloatEnable.pNext = &atomicFloat2Enable; - } - } + /* VK_KHR_pipeline_executable_properties whenever the device offers it, with its one feature + as reported, so a plugin can read back the register, memory and compiler statistics of its + own pipelines. Enabling it changes nothing by itself: the work happens only for pipelines + created with its capture flags, and the spec forbids those from changing the compiled code. + Measured on AMD's Windows driver, statistics capture is free and internal representations + add about 3% to compile time, with cache hits and run time unchanged; RADV has never done + anything for the bare enable either. */ + VkPhysicalDevicePipelineExecutablePropertiesFeaturesKHR pipelineExecutableEnable = {}; + pipelineExecutableEnable.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PIPELINE_EXECUTABLE_PROPERTIES_FEATURES_KHR; + const bool pipelineExecutableEnabled = + deviceExtensionAvailable(vk, physicalDeviceHandle, VK_KHR_PIPELINE_EXECUTABLE_PROPERTIES_EXTENSION_NAME); + if (pipelineExecutableEnabled) { + VkPhysicalDeviceFeatures2 pipelineExecutableFeatures = {}; + pipelineExecutableFeatures.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2; + pipelineExecutableFeatures.pNext = &pipelineExecutableEnable; + vk.vkGetPhysicalDeviceFeatures2(physicalDeviceHandle, &pipelineExecutableFeatures); + pipelineExecutableEnable.pNext = enabled.f14.pNext; + enabled.f14.pNext = &pipelineExecutableEnable; } /* The one extension that is not a choice: a device advertising VK_KHR_portability_subset @@ -852,7 +850,7 @@ /* Sized for every optional extension that can be pushed below, not for how many usually coexist: undersizing this the day another push is added would be a stack overwrite, not an error. */ - const char *enabledExtensions[5]; + const char *enabledExtensions[6]; uint32_t enabledExtensionCount = 0; if (exportType) enabledExtensions[enabledExtensionCount++] = exportExtensionName; @@ -862,6 +860,8 @@ enabledExtensions[enabledExtensionCount++] = VK_EXT_SHADER_ATOMIC_FLOAT_EXTENSION_NAME; if (atomicFloat2Enabled) enabledExtensions[enabledExtensionCount++] = VK_EXT_SHADER_ATOMIC_FLOAT_2_EXTENSION_NAME; + if (pipelineExecutableEnabled) + enabledExtensions[enabledExtensionCount++] = VK_KHR_PIPELINE_EXECUTABLE_PROPERTIES_EXTENSION_NAME; if (portabilitySubset) enabledExtensions[enabledExtensionCount++] = portabilitySubsetName; @@ -1016,7 +1016,13 @@ } return false; }; - exportNarrowsFlag = allowsHostVisible(false) && !allowsHostVisible(true); + /* A discrete card counts as narrowing whether or not its driver does: NVIDIA's Windows + driver lets an exportable buffer bind to its host visible device local type and then + backs it with memory the card reaches at bus speed. A 1080p float box blur pass took + 1.0 to 1.6 ms there against 0.02 for the same type plain or device local memory + exportable, on an RTX 4060 Ti, and every frame plane landed in it. Unified memory + has no bus to lose, so there the driver alone decides. */ + exportNarrowsFlag = allowsHostVisible(false) && (!unifiedMemoryFlag || !allowsHostVisible(true)); } state = State::Ready; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/src/core/vsvulkan.h new/vapoursynth-R82/src/core/vsvulkan.h --- old/vapoursynth-R81/src/core/vsvulkan.h 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/src/core/vsvulkan.h 2026-11-01 20:02:33.000000000 +0100 @@ -498,7 +498,7 @@ /* Whether a host visible device local memory type sits on a heap that is the card, or most of it, rather than a BAR window: resizable BAR in the loose sense. Without ReBAR, or with - it gated off per application as NVIDIA does on Windows, drivers expose the same type on + it gated off per application as NVIDIA's Windows driver can, drivers expose the same type on the classic 256 MiB window (246 MiB on NVIDIA), which holds a few small buffers and no frames. findMemoryType never lets a preference land on such a window, only a requirement, and the transfer stages uploads through VRAM only when this is set. */ @@ -507,7 +507,10 @@ /* Whether exporting a plane would cost it its host visibility: a buffer shaped like a plane may bind to a host visible device local type when plain, and to none once it is created exportable. AMD's Windows driver does this on its discrete and integrated devices alike; - RADV, NVIDIA and MoltenVK do not. Probed at creation where there is such a type to lose. */ + RADV, NVIDIA and MoltenVK do not, so on a discrete card the core does it for them + (createBufferPooled), since NVIDIA's Windows driver backs exportable host visible memory + with something the card reaches at bus speed. Unified memory narrows only where the + driver does. Probed at creation where there is such a type to lose. */ bool exportNarrowsHostVisible() const { return exportNarrowsFlag; } /* Whether the frames GPUUpload writes are allocated plain, wherever export narrows host diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/src/core/vsvulkanalloc.cpp new/vapoursynth-R82/src/core/vsvulkanalloc.cpp --- old/vapoursynth-R81/src/core/vsvulkanalloc.cpp 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/src/core/vsvulkanalloc.cpp 2026-11-01 20:02:33.000000000 +0100 @@ -373,8 +373,13 @@ /* The external info here and the export info on the block must appear together or binding is invalid, and it restricts the compatible memory types -- on some drivers to ones that are not host visible (exportNarrowsHostVisible) -- which is why a request that requires - host visibility never asks for it. */ + host visibility never asks for it. Where the device narrows but the driver does not, an + exportable buffer's host preferences are dropped here, so it lands where the driver's + own narrowing would have put it. */ const bool exportable = wantExportable && exportType && !(requiredFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT); + if (exportable && exportNarrowsFlag) + preferredFlags &= ~(VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT | + VK_MEMORY_PROPERTY_HOST_CACHED_BIT); VkExternalMemoryBufferCreateInfo externalInfo = {}; externalInfo.sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_BUFFER_CREATE_INFO; externalInfo.handleTypes = exportType; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/src/core/vsvulkanframe.cpp new/vapoursynth-R82/src/core/vsvulkanframe.cpp --- old/vapoursynth-R81/src/core/vsvulkanframe.cpp 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/src/core/vsvulkanframe.cpp 2026-11-01 20:02:33.000000000 +0100 @@ -146,10 +146,11 @@ return true; } -/* Device local required, host visible preferred: on resizable BAR systems a plane lands - writable straight from the CPU and uploads never touch staging -- where being exportable - does not rule that out, which is why upload targets are made plain where it does. Pooled, - since planes are exactly what the block allocator exists for. */ +/* Device local required, host visible preferred: on resizable BAR systems a plain plane lands + writable straight from the CPU and uploads never touch staging. An exportable one does not + wherever export narrows host visibility, every discrete card included, which is why upload + targets are made plain there. Pooled, since planes are exactly what the block allocator + exists for. */ bool createGPUPlane(VSVulkanDevice &device, uint32_t width, uint32_t height, int bytesPerSample, ptrdiff_t stride, bool exportable, VSVulkanPlane &plane, std::string &errorMessage) { plane.reset(); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/src/vspipe/ebml.cpp new/vapoursynth-R82/src/vspipe/ebml.cpp --- old/vapoursynth-R81/src/vspipe/ebml.cpp 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/src/vspipe/ebml.cpp 2026-11-01 20:02:33.000000000 +0100 @@ -58,9 +58,11 @@ } static void ebmlPutUnknownSize(EbmlBuffer &dst) { - /* One byte of all ones. Wider forms exist but readers handle the short one and it keeps the - header compact. */ - dst.push_back(0xFF); + /* The eight byte form, the length marker followed by all ones, which is what other muxers + write. The one byte form means the same and is valid, but mpv refuses a cluster opened with + it and then plays nothing at all. */ + dst.push_back(0x01); + dst.insert(dst.end(), 7, 0xFF); } static int uintLength(uint64_t value) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/src/vspipe/matroska.cpp new/vapoursynth-R82/src/vspipe/matroska.cpp --- old/vapoursynth-R81/src/vspipe/matroska.cpp 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/src/vspipe/matroska.cpp 2026-11-01 20:02:33.000000000 +0100 @@ -19,12 +19,14 @@ */ #include "matroska.h" +#include "VSConstants4.h" #include "../core/version.h" #include <algorithm> #include <cerrno> #include <cstring> #include <cmath> +#include <numeric> #include <sys/types.h> #include <sys/stat.h> @@ -57,9 +59,21 @@ constexpr uint32_t idCodecID = 0x86; constexpr uint32_t idVideo = 0xE0; +constexpr uint32_t idFlagInterlaced = 0x9A; +constexpr uint32_t idFieldOrder = 0x9D; constexpr uint32_t idPixelWidth = 0xB0; constexpr uint32_t idPixelHeight = 0xBA; +constexpr uint32_t idDisplayWidth = 0x54B0; +constexpr uint32_t idDisplayHeight = 0x54BA; +constexpr uint32_t idDisplayUnit = 0x54B2; constexpr uint32_t idColourSpace = 0x2EB524; +constexpr uint32_t idColour = 0x55B0; +constexpr uint32_t idMatrixCoefficients = 0x55B1; +constexpr uint32_t idChromaSitingHorz = 0x55B7; +constexpr uint32_t idChromaSitingVert = 0x55B8; +constexpr uint32_t idRange = 0x55B9; +constexpr uint32_t idTransferCharacteristics = 0x55BA; +constexpr uint32_t idPrimaries = 0x55BB; constexpr uint32_t idAudio = 0xE1; constexpr uint32_t idSamplingFrequency = 0xB5; @@ -90,6 +104,23 @@ constexpr uint64_t trackTypeVideo = 1; constexpr uint64_t trackTypeAudio = 2; +constexpr int64_t flagInterlacedInterlaced = 1; +constexpr int64_t flagInterlacedProgressive = 2; +/* The interleaved orders, for frames holding both fields line by line with the top field's line + first, which is how every VapourSynth frame stores them; 1 and 6 mean fields stored one after + the other. They are also what ffmpeg writes for interleaved frames. */ +constexpr int64_t fieldOrderTopFirstInterleaved = 9; +constexpr int64_t fieldOrderBottomFirstInterleaved = 14; + +constexpr int64_t rangeBroadcast = 1; +constexpr int64_t rangeFull = 2; + +/* Horizontal siting on the left sample or halfway, vertical on the top line or halfway. */ +constexpr int64_t chromaSitingCollocated = 1; +constexpr int64_t chromaSitingHalf = 2; + +constexpr uint64_t displayUnitAspectRatio = 3; + /* One microsecond. Fine enough that variable frame durations survive the conversion to integer ticks essentially intact, while leaving a block able to sit 32 milliseconds away from its cluster, which is far more than the interval a new cluster gets opened at anyway. */ @@ -271,6 +302,92 @@ return format.bitsPerSample == 16 || format.bitsPerSample == 24 || format.bitsPerSample == 32; } +MatroskaVideoDescription MatroskaWriter::describeVideoFrame(const VSFrame *frame, const VSAPI *vsapi) { + MatroskaVideoDescription description; + const VSMap *props = vsapi->getFramePropertiesRO(frame); + const VSVideoFormat *format = vsapi->getVideoFrameFormat(frame); + + auto getInt = [&](const char *key, int64_t &value) { + int err = 0; + value = vsapi->mapGetInt(props, key, 0, &err); + return !err; + }; + + int64_t value = 0; + + /* Both sides number these with the ITU-T H.273 code points, so a value goes through unchanged + wherever Matroska lists it. Unspecified is left out like an absent property, since it is + what the header says by default. */ + if (getInt("_Matrix", value) && + (value == VSC_MATRIX_RGB || value == VSC_MATRIX_BT709 || (value >= VSC_MATRIX_FCC && value <= VSC_MATRIX_ICTCP))) + description.matrixCoefficients = value; + if (getInt("_Transfer", value) && + (value == VSC_TRANSFER_BT709 || (value >= VSC_TRANSFER_BT470_M && value <= VSC_TRANSFER_ARIB_B67))) + description.transferCharacteristics = value; + if (getInt("_Primaries", value) && + (value == VSC_PRIMARIES_BT709 || (value >= VSC_PRIMARIES_BT470_M && value <= VSC_PRIMARIES_ST432_1) || value == VSC_PRIMARIES_EBU3213_E)) + description.primaries = value; + + if (getInt("_Range", value) && (value == VSC_RANGE_LIMITED || value == VSC_RANGE_FULL)) + description.range = (value == VSC_RANGE_FULL) ? rangeFull : rangeBroadcast; + + /* Chroma location only means something where chroma is subsampled, which is also the only + place the resizer sets it. Matroska has no vertical siting on the bottom line, so the two + bottom locations are left out rather than half described. */ + if (format->colorFamily == cfYUV && (format->subSamplingW || format->subSamplingH) && getInt("_ChromaLocation", value)) { + switch (value) { + case VSC_CHROMA_LEFT: + description.chromaSitingHorz = chromaSitingCollocated; + description.chromaSitingVert = chromaSitingHalf; + break; + case VSC_CHROMA_CENTER: + description.chromaSitingHorz = chromaSitingHalf; + description.chromaSitingVert = chromaSitingHalf; + break; + case VSC_CHROMA_TOP_LEFT: + description.chromaSitingHorz = chromaSitingCollocated; + description.chromaSitingVert = chromaSitingCollocated; + break; + case VSC_CHROMA_TOP: + description.chromaSitingHorz = chromaSitingHalf; + description.chromaSitingVert = chromaSitingCollocated; + break; + } + } + + if (getInt("_FieldBased", value)) { + if (value == VSC_FIELD_PROGRESSIVE) { + description.flagInterlaced = flagInterlacedProgressive; + } else if (value == VSC_FIELD_TOP || value == VSC_FIELD_BOTTOM) { + description.flagInterlaced = flagInterlacedInterlaced; + description.fieldOrder = (value == VSC_FIELD_TOP) ? fieldOrderTopFirstInterleaved : fieldOrderBottomFirstInterleaved; + } + } + + /* The sample aspect ratio is stored as the display aspect ratio it produces, which keeps it + exact where display dimensions in pixels would have to be rounded. Square pixels need + nothing, being what the header implies anyway, and a ratio too large to multiply out is + not a real one. */ + int64_t sarNum = 0; + int64_t sarDen = 0; + if (getInt("_SARNum", sarNum) && getInt("_SARDen", sarDen) && sarNum > 0 && sarDen > 0) { + const int64_t sarDivisor = std::gcd(sarNum, sarDen); + sarNum /= sarDivisor; + sarDen /= sarDivisor; + const int64_t width = vsapi->getFrameWidth(frame, 0); + const int64_t height = vsapi->getFrameHeight(frame, 0); + if (sarNum != sarDen && sarNum <= INT64_MAX / width && sarDen <= INT64_MAX / height) { + const int64_t darNum = width * sarNum; + const int64_t darDen = height * sarDen; + const int64_t darDivisor = std::gcd(darNum, darDen); + description.displayAspectNum = darNum / darDivisor; + description.displayAspectDen = darDen / darDivisor; + } + } + + return description; +} + bool MatroskaWriter::writeBuffer(const EbmlBuffer &buffer, const char *context, std::string &errorMessage) { if (!outFile || buffer.empty()) return true; @@ -305,7 +422,10 @@ ebmlUInt(ebmlHead, idEBMLMaxIDLength, 4); ebmlUInt(ebmlHead, idEBMLMaxSizeLength, 8); ebmlString(ebmlHead, idDocType, "matroska"); - ebmlUInt(ebmlHead, idDocTypeVersion, 2); + /* The version has to be at least that of any element the file contains, and the colour + description and field order are version 4 elements. Reading still needs only version 2, + since a reader that does not know them can skip them, which is how ffmpeg sets both. */ + ebmlUInt(ebmlHead, idDocTypeVersion, 4); ebmlUInt(ebmlHead, idDocTypeReadVersion, 2); ebmlMaster(header, idEBML, ebmlHead); @@ -368,9 +488,19 @@ ? static_cast<uint64_t>(1000000000LL * track.frameDurationNum / track.frameDurationDen) : static_cast<uint64_t>(std::floor(1e9 * static_cast<double>(track.frameDurationNum) / static_cast<double>(track.frameDurationDen)))); + const MatroskaVideoDescription &description = track.description; EbmlBuffer video; + if (description.flagInterlaced >= 0) + ebmlUInt(video, idFlagInterlaced, static_cast<uint64_t>(description.flagInterlaced)); + if (description.fieldOrder >= 0) + ebmlUInt(video, idFieldOrder, static_cast<uint64_t>(description.fieldOrder)); ebmlUInt(video, idPixelWidth, static_cast<uint64_t>(track.width)); ebmlUInt(video, idPixelHeight, static_cast<uint64_t>(track.height)); + if (description.displayAspectNum > 0 && description.displayAspectDen > 0) { + ebmlUInt(video, idDisplayWidth, static_cast<uint64_t>(description.displayAspectNum)); + ebmlUInt(video, idDisplayHeight, static_cast<uint64_t>(description.displayAspectDen)); + ebmlUInt(video, idDisplayUnit, displayUnitAspectRatio); + } /* The layout of the frames is carried entirely by this fourcc, stored little endian the same way the equivalent AVI field is. */ uint8_t colourSpace[4] = { @@ -380,6 +510,22 @@ static_cast<uint8_t>(track.fourCC >> 24) }; ebmlBinary(video, idColourSpace, colourSpace, sizeof(colourSpace)); + + EbmlBuffer colour; + if (description.matrixCoefficients >= 0) + ebmlUInt(colour, idMatrixCoefficients, static_cast<uint64_t>(description.matrixCoefficients)); + if (description.chromaSitingHorz >= 0) + ebmlUInt(colour, idChromaSitingHorz, static_cast<uint64_t>(description.chromaSitingHorz)); + if (description.chromaSitingVert >= 0) + ebmlUInt(colour, idChromaSitingVert, static_cast<uint64_t>(description.chromaSitingVert)); + if (description.range >= 0) + ebmlUInt(colour, idRange, static_cast<uint64_t>(description.range)); + if (description.transferCharacteristics >= 0) + ebmlUInt(colour, idTransferCharacteristics, static_cast<uint64_t>(description.transferCharacteristics)); + if (description.primaries >= 0) + ebmlUInt(colour, idPrimaries, static_cast<uint64_t>(description.primaries)); + if (!colour.empty()) + ebmlMaster(video, idColour, colour); ebmlMaster(entry, idVideo, video); } else { ebmlString(entry, idCodecID, track.isFloat ? "A_PCM/FLOAT/IEEE" : "A_PCM/INT/LIT"); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/src/vspipe/matroska.h new/vapoursynth-R82/src/vspipe/matroska.h --- old/vapoursynth-R81/src/vspipe/matroska.h 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/src/vspipe/matroska.h 2026-11-01 20:02:33.000000000 +0100 @@ -29,6 +29,24 @@ #include <string> #include <vector> +/* What a video track's header says about its colour, field order and pixel aspect, in Matroska's + own numbering. Every entry is optional and stays out of the header while negative. */ +struct MatroskaVideoDescription { + int64_t matrixCoefficients = -1; + int64_t transferCharacteristics = -1; + int64_t primaries = -1; + int64_t range = -1; + int64_t chromaSitingHorz = -1; + int64_t chromaSitingVert = -1; + int64_t flagInterlaced = -1; + int64_t fieldOrder = -1; + /* The display aspect ratio, zero when the pixels are square or their shape is unknown. */ + int64_t displayAspectNum = 0; + int64_t displayAspectDen = 0; + + bool operator==(const MatroskaVideoDescription &) const = default; +}; + struct MatroskaTrackInfo { bool isVideo = true; @@ -38,6 +56,7 @@ uint32_t fourCC = 0; int64_t frameDurationNum = 0; // seconds per frame, zero when the rate is unknown int64_t frameDurationDen = 1; + MatroskaVideoDescription description; /* Audio */ int sampleRate = 0; @@ -70,6 +89,11 @@ static bool getVideoFourCC(const VSVideoFormat &format, bool hasAlpha, uint32_t &fourCC); static bool isAudioFormatSupported(const VSAudioFormat &format); + /* Translates the reserved colour, field and aspect ratio properties of a video frame into + the track header's terms. Properties that are absent, unspecified or have no Matroska + equivalent are left out. */ + static MatroskaVideoDescription describeVideoFrame(const VSFrame *frame, const VSAPI *vsapi); + private: bool writeBuffer(const EbmlBuffer &buffer, const char *context, std::string &errorMessage); bool startCluster(int64_t timestampNs, std::string &errorMessage); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/src/vspipe/vspipe.cpp new/vapoursynth-R82/src/vspipe/vspipe.cpp --- old/vapoursynth-R81/src/vspipe/vspipe.cpp 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/src/vspipe/vspipe.cpp 2026-11-01 20:02:33.000000000 +0100 @@ -400,6 +400,10 @@ return true; } +static int outputNodeFlags(const FILE *outFile) { + return outFile ? 0 : sgfAllowGPUResident; +} + static void outputFrame(const VSFrame *frame, VSPipeOutputData *data) { if (data->outputError || !data->outFile) return; @@ -841,6 +845,12 @@ std::map<int, const VSFrame *> arrivedAlpha; const VSFrame *pendingAlphaFrame = nullptr; + /* What the track header says about the colour, field order and aspect ratio, taken from the + first frame. A track cannot change it partway, so a later frame that disagrees is still + written, with a warning the first time it happens. */ + MatroskaVideoDescription description; + bool descriptionChangeReported = false; + int frameCount() const { return isVideo ? vi->numFrames : ai->numFrames; } @@ -975,7 +985,7 @@ bool failed = false; for (int outputIndex : outputIndices) { - VSNode *node = singleNode ? singleNode : vssapi->getOutputNode(se, outputIndex); + VSNode *node = singleNode ? singleNode : vssapi->getOutputNodeEx(se, outputIndex, outputNodeFlags(outFile)); if (!node) { fprintf(stderr, "Error: failed to retrieve output node %d\n", outputIndex); failed = true; @@ -993,7 +1003,7 @@ /* An attached alpha clip is carried as a fourth plane of the same track, when the format has a fourcc that says so. Where it does not, the alpha is dropped rather than the whole output being refused, but never silently. */ - stream.alphaNode = singleNode ? singleAlphaNode : vssapi->getOutputAlphaNode(se, outputIndex); + stream.alphaNode = singleNode ? singleAlphaNode : vssapi->getOutputAlphaNodeEx(se, outputIndex, outputNodeFlags(outFile)); MatroskaTrackInfo info; info.isVideo = stream.isVideo; @@ -1061,11 +1071,7 @@ std::vector<uint8_t> buffer(maxBufferSize); MatroskaWriter writer; - - if (!failed && !writer.initialize(outFile, trackInfos, errorMessage)) { - fprintf(stderr, "%s\n", errorMessage.c_str()); - failed = true; - } + bool headerWritten = false; if (!failed && timecodesFile && fprintf(timecodesFile, "# timecode format v2\n") < 0) { fprintf(stderr, "Error: failed to write timecodes file header, errno: %d\n", errno); @@ -1198,6 +1204,30 @@ break; MuxStream &stream = streams[nextStream]; + + /* The header waits until here, the first point at which every stream holds its first + frame, since that frame's properties are where each video track's colour, field order + and aspect ratio come from. */ + if (!headerWritten) { + for (auto &iter : streams) { + if (iter.isVideo && iter.pendingFrame) { + iter.description = MatroskaWriter::describeVideoFrame(iter.pendingFrame, vsapi); + trackInfos[iter.trackIndex].description = iter.description; + } + } + if (!writer.initialize(outFile, trackInfos, errorMessage)) { + fprintf(stderr, "%s\n", errorMessage.c_str()); + failed = true; + break; + } + headerWritten = true; + } else if (stream.isVideo && !stream.descriptionChangeReported && + MatroskaWriter::describeVideoFrame(stream.pendingFrame, vsapi) != stream.description) { + fprintf(stderr, "Warning: frame %d of output %d has different colour, field order or aspect ratio properties than its first frame, which the Matroska track header describes\n", + stream.frameIndex, stream.outputIndex); + stream.descriptionChangeReported = true; + } + size_t frameSize = stream.isVideo ? muxVideoFrameSize(stream.vi, stream.alphaNode != nullptr) : muxAudioFrameSize(stream.pendingFrame, vsapi); if (!writer.writeFrameHeader(stream.trackIndex, stream.pendingTimeNs, frameSize, true, errorMessage) || @@ -1368,7 +1398,7 @@ "\n" "Special output options for <outfile>:\n" " - Write to stdout\n" - " -- No output\n" + " -- No output, GPU resident frames are not downloaded\n" "\n" "Examples:\n" " Show script info:\n" @@ -1898,14 +1928,14 @@ VSNode *node = nullptr; VSNode *alphaNode = nullptr; if (!matroskaAllTracks) { - nodeHolder = wrapNode(vssapi->getOutputNode(se, opts.outputIndex), vsapi); + nodeHolder = wrapNode(vssapi->getOutputNodeEx(se, opts.outputIndex, outputNodeFlags(outFile)), vsapi); node = nodeHolder.get(); if (!node) { fprintf(stderr, "Failed to retrieve output node. Invalid index specified?\n"); return 1; } - alphaHolder = wrapNode(vssapi->getOutputAlphaNode(se, opts.outputIndex), vsapi); + alphaHolder = wrapNode(vssapi->getOutputAlphaNodeEx(se, opts.outputIndex, outputNodeFlags(outFile)), vsapi); alphaNode = alphaHolder.get(); // disable cache since no frame is ever requested twice diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vapoursynth-R81/test/environment_test.py new/vapoursynth-R82/test/environment_test.py --- old/vapoursynth-R81/test/environment_test.py 2026-10-04 22:48:11.000000000 +0200 +++ new/vapoursynth-R82/test/environment_test.py 2026-11-01 20:02:33.000000000 +0100 @@ -2,34 +2,43 @@ import gc import io import multiprocessing +import pickle import threading import time +import traceback import unittest import weakref -from concurrent.futures import ProcessPoolExecutor from threading import Lock import vapoursynth as vs class StubPolicy(vs.EnvironmentPolicy): + # The current environment is kept per thread, as the policy contract describes it and as + # VSScript's policy keeps it. The module switches environments around every callback it runs + # on a worker thread, restoring what it found there afterwards. With one environment shared + # by all threads, a worker that entered while the main thread was still inside a use() block + # saved that environment, and restored it after the main thread had left: the policy then + # held a strong reference past teardown, and the collection checks failed at random. def __init__(self) -> None: - self._current = None + self._local = threading.local() self._api = None def on_policy_registered(self, special_api): self._api = special_api - self._current = None + self._local = threading.local() def on_policy_cleared(self): assert self._api is not None - self._current = None + self._local = threading.local() def get_current_environment(self): - return self._current + return getattr(self._local, "current", None) def set_environment(self, environment): - self._current = environment + previous = getattr(self._local, "current", None) + self._local.current = environment + return previous @contextlib.contextmanager @@ -42,6 +51,22 @@ pol._api.unregister_policy() [email protected] +def _holding(lock, *threads): + # For tests that hold the lock a frame request blocks on. However the block exits, the lock + # is released and the threads that were started are joined. A failed check that left it held + # would hang the run instead of failing it: the blocked request never completes, and neither + # does a teardown waiting for it, nor the test process. + lock.acquire() + try: + yield + finally: + lock.release() + for thread in threads: + if thread.ident is not None: + thread.join() + + test_functions = {} counter = 0 @@ -51,6 +76,34 @@ return func(EnvironmentTest()) +# How long one test may run in its process, and how long that process then gets to exit. Every +# test takes seconds at most; the margins are for slow CI runners. +SUBPROCESS_TEST_TIMEOUT = 120 +SUBPROCESS_EXIT_TIMEOUT = 30 + + +class _RemoteTraceback(Exception): + # The test process's traceback, attached as the cause of the exception raised here again, the + # way concurrent.futures shows one. + def __str__(self): + return self.args[0] + + +def _run_in_subprocess(ident, conn): + try: + _wrap_with(ident) + outcome = None + except BaseException as e: + remote = "".join(traceback.format_exception(e)) + try: + pickle.dumps(e) + except Exception: + e = RuntimeError(f"{type(e).__name__}: {e}") + outcome = (e, remote) + conn.send(outcome) + conn.close() + + def subprocess_runner(func): global counter my_counter = counter @@ -58,8 +111,44 @@ test_functions[my_counter] = func def _wrapper(self): - with ProcessPoolExecutor(max_workers=1, mp_context=multiprocessing.get_context("spawn")) as executor: - executor.submit(_wrap_with, my_counter).result() + # Each test runs in a fresh process, against a deadline: a hang, whether a deadlock in + # VapourSynth or a thread left waiting for good, fails the test instead of stalling the + # whole run. A process that misses its deadline, or does not exit after reporting, is + # killed rather than waited for. What the test raised is raised here again, so failures, + # errors and skips are reported as before. + ctx = multiprocessing.get_context("spawn") + receiver, sender = ctx.Pipe(duplex=False) + proc = ctx.Process(target=_run_in_subprocess, args=(my_counter, sender), daemon=True) + proc.start() + sender.close() + outcome = None + finished = reported = False + try: + # True once the result arrives, or the process dies without sending one + finished = receiver.poll(SUBPROCESS_TEST_TIMEOUT) + if finished: + try: + outcome = receiver.recv() + reported = True + except EOFError: + pass + proc.join(SUBPROCESS_EXIT_TIMEOUT) + finally: + lingering = proc.is_alive() + if lingering: + proc.kill() + proc.join() + receiver.close() + + if not finished: + self.fail(f"did not finish within {SUBPROCESS_TEST_TIMEOUT} s, its process was killed") + if not reported: + self.fail(f"its process exited with code {proc.exitcode} before reporting a result") + if outcome is not None: + exc, remote = outcome + raise exc from _RemoteTraceback(remote) + if lingering: + self.fail(f"passed, but its process did not exit within {SUBPROCESS_EXIT_TIMEOUT} s and was killed") return _wrapper @@ -442,34 +531,29 @@ with _with_policy() as pol: env = pol._api.create_environment() wrapped = pol._api.wrap_environment(env) - - with wrapped.use(): - core = vs.core.core - clip = core.std.BlankClip(width=16, height=16, length=2) - clip = core.std.ModifyFrame(clip, clip, modify_func) - lock.acquire() - fut = clip.get_frame_async(0) - - fut_ref = weakref.ref(fut) - self.assertIsNotNone(fut_ref()) - thread = threading.Thread(target=lambda: pol._api.destroy_environment(env)) - thread.start() - thread.join(0.25) + with _holding(lock, thread): + with wrapped.use(): + core = vs.core.core + clip = core.std.BlankClip(width=16, height=16, length=2) + clip = core.std.ModifyFrame(clip, clip, modify_func) + fut = clip.get_frame_async(0) - gc.collect() - gc.collect() - gc.collect() + fut_ref = weakref.ref(fut) + self.assertIsNotNone(fut_ref()) - # Environment should still be alive because wait_futures blocks destroy_environment - # until the pending future resolves. - self.assertIsNotNone(wrapped.env()) - self.assertTrue(running) + thread.start() + thread.join(0.25) - lock.release() + gc.collect() + gc.collect() + gc.collect() - thread.join() + # Environment should still be alive because wait_futures blocks destroy_environment + # until the pending future resolves. + self.assertIsNotNone(wrapped.env()) + self.assertTrue(running) # The future should have resolved successfully. self.assertFalse(running) @@ -495,28 +579,30 @@ with _with_policy() as pol: env = pol._api.create_environment() wrapped = pol._api.wrap_environment(env) - - with wrapped.use(): - core = vs.core.core - clip = core.std.BlankClip(width=16, height=16, length=2) - clip = core.std.ModifyFrame(clip, clip, modify_func) - lock.acquire() - fut = clip.get_frame_async(0) - thread = threading.Thread(target=lambda: pol._api.destroy_environment(env)) - thread.start() - thread.join(0.25) - - # destroy_environment is blocked waiting for the first request, and every - # request made after it claimed the environment must be refused, keyed on - # the environment owning the node even though none is current here - self.assertIsNotNone(wrapped.env()) - with self.assertRaisesRegex(vs.Error, "being destroyed"): - clip.get_frame(1) - self.assertIsInstance(clip.get_frame_async(1).exception(), vs.Error) - lock.release() - thread.join() + with _holding(lock, thread): + with wrapped.use(): + core = vs.core.core + clip = core.std.BlankClip(width=16, height=16, length=2) + clip = core.std.ModifyFrame(clip, clip, modify_func) + fut = clip.get_frame_async(0) + + thread.start() + thread.join(0.25) + + # destroy_environment is blocked waiting for the first request, and every + # request made after it claimed the environment must be refused, keyed on + # the environment owning the node even though none is current here; checked, + # since the worker blocked in modify_func is inside an environment switch right + # now. The asynchronous request goes first and with a timeout: both kinds share + # one admission check, and a synchronous request it let through would block on + # this lock for good instead of failing. + self.assertIsNotNone(wrapped.env()) + self.assertIsNone(pol.get_current_environment()) + self.assertIsInstance(clip.get_frame_async(1).exception(timeout=10), vs.Error) + with self.assertRaisesRegex(vs.Error, "being destroyed"): + clip.get_frame(1) self.assertIsInstance(fut.result(), vs.VideoFrame) @@ -599,20 +685,17 @@ clip = core.std.BlankClip(width=16, height=16, length=2) clip = core.std.ModifyFrame(clip, clip, modify_func) - lock.acquire() - with wrapped_b.use(): - fut = clip.get_frame_async(0) - - # the request was made from B on a node owned by A, so destroying B has to - # wait for it just like destroying A would thread = threading.Thread(target=lambda: pol._api.destroy_environment(env_b)) - thread.start() - thread.join(0.25) - self.assertIsNotNone(wrapped_b.env()) - self.assertTrue(thread.is_alive()) - - lock.release() - thread.join() + with _holding(lock, thread): + with wrapped_b.use(): + fut = clip.get_frame_async(0) + + # the request was made from B on a node owned by A, so destroying B has to + # wait for it just like destroying A would + thread.start() + thread.join(0.25) + self.assertIsNotNone(wrapped_b.env()) + self.assertTrue(thread.is_alive()) self.assertIsInstance(fut.result(), vs.VideoFrame) pol._api.destroy_environment(env_a) @@ -834,24 +917,22 @@ env = pol._api.create_environment() wrapped = pol._api.wrap_environment(env) - with wrapped.use(): - core = vs.core.core - clip = core.std.BlankClip(width=16, height=16, length=1) - clip = core.std.ModifyFrame(clip, clip, modify_func) - lock.acquire() - fut = clip.get_frame_async(0) - - seen_alive = [] - - def dispose(f): - # from inside the environment's own frame callback: the call returns at - # once and hands the teardown to a helper thread - pol._api.destroy_environment(env) - seen_alive.append(wrapped.alive) + seen_alive = [] - fut.add_done_callback(dispose) + def dispose(f): + # from inside the environment's own frame callback: the call returns at + # once and hands the teardown to a helper thread + pol._api.destroy_environment(env) + seen_alive.append(wrapped.alive) + + with _holding(lock): + with wrapped.use(): + core = vs.core.core + clip = core.std.BlankClip(width=16, height=16, length=1) + clip = core.std.ModifyFrame(clip, clip, modify_func) + fut = clip.get_frame_async(0) + fut.add_done_callback(dispose) - lock.release() self.assertIsInstance(fut.result(timeout=10), vs.VideoFrame) for _ in range(200): @@ -881,19 +962,17 @@ clip = core.std.BlankClip(width=16, height=16, length=1) clip = core.std.ModifyFrame(clip, clip, modify_func) - with wrapped_caller.use(): - lock.acquire() - fut = clip.get_frame_async(0) - - seen_alive = [] - - def dispose(f): - pol._api.destroy_environment(caller) - seen_alive.append(wrapped_caller.alive) + seen_alive = [] - fut.add_done_callback(dispose) + def dispose(f): + pol._api.destroy_environment(caller) + seen_alive.append(wrapped_caller.alive) + + with _holding(lock): + with wrapped_caller.use(): + fut = clip.get_frame_async(0) + fut.add_done_callback(dispose) - lock.release() self.assertIsInstance(fut.result(timeout=10), vs.VideoFrame) for _ in range(200):
