Compare commits

...
324 Commits
Author SHA1 Message Date
z 973075279e docs(brand): add hero banner 2026-06-28 20:21:49 -07:00
z caa5c1c440 chore(brand): dynamic hero banner 2026-06-28 20:21:47 -07:00
Antje WorringandClaude Opus 4.8 d060c20ff5 docs: tidy LLM.md indexes; CLAUDE.md -> LLM.md symlink convention
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:12:03 -07:00
Hanzo DevandGitHub 249bb50669 Merge branch 'Kosinkadink:main' into main 2026-06-03 12:53:50 -07:00
Antje WorringandClaude Opus 4.8 6394f809b8 Add Claude Code project docs (CLAUDE.md, LLM.md)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:44:14 -07:00
4ee72c065d fix(metadata): stop double-stringifying prompt in MP4 metadata
`Video Combine` stored `video_metadata["prompt"] = json.dumps(prompt)`
while every other field is stored as a raw object. `ffmpeg_process`
then runs `json.dumps` on each value, so `prompt` ends up double-
encoded in the embedded MP4 metadata.

Match the `extra_pnginfo` branch and store the raw object. The PNG
metadata path on line 354 is unchanged.

The original double-wrap was an intentional workaround for JSON's
stricter NaN handling (per @AustinMroz). It predates the
consumer-facing schema introduced in #627 and is no longer needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:38:39 -07:00
Austin Mroz a6879b8388 On mp4 audio creation, force copy metadata fields
Since `prompt` and `workflow` are non-standard metadata fields for mp4
files, they require an additional flag during the audio pass to ensure
the metadata is copied.
2026-05-11 13:10:34 -07:00
Terry JiaandAustinMroz 2984ec4c4b Fix audio preview sizing in vueNodes mode 2026-04-06 09:07:00 -07:00
Austin Mroz 449839959f Display error if duplicate install detected
Detection may not be the most reliable if one of the duplicates is not
seeing updates and happens to install first. I'll keep pondering a more
reliable way to do this. The only ideas that come to mind are a little
too hacky and unsafe for my current standards.
2026-03-17 10:08:33 -07:00
Austin Mroz 59529f50e6 Fix no-js warning, crash on duplicate install 2026-03-17 10:08:33 -07:00
Terry JiaandAustinMroz 993082e4f2 fix vueNodes/node 2.0 display issue 2026-01-12 00:42:29 -08:00
silveroxidesandAustinMroz c67ff3ef37 Fix for improper metadata saving which caused errors during playback with video players lile mpc-hc 2026-01-10 15:26:37 -08:00
Austin Mroz 3234937ff5 Add auth headers when used on cloud 2025-12-17 14:23:30 -08:00
Austin Mroz 537f3a0226 Fix latent previews on older versions 2025-11-27 09:58:49 -08:00
AustinMrozandGitHub 4f804bb23f Merge 1.7.8 2025-11-26 09:40:13 -08:00
Austin Mroz b9cc3f23aa Merge batch manager fix 2025-11-26 09:32:31 -08:00
Austin Mroz 2571e60a1c Hopefully fix flux2 latent previews 2025-11-26 09:20:57 -08:00
Austin Mroz 4bc8d33127 Fix sizing of animated preview in vue mode 2025-11-25 14:05:46 -08:00
Austin Mroz a206b3443c Fix SelectLatest
Fix a mistaken usage of this from a hasty prior pass on subgraph
compatibility.

Ensure options can't be uninitialized in SelectLatest
2025-11-25 14:05:42 -08:00
Austin Mroz f2a18a1f86 Skip preview update if all parameters unchanged 2025-11-25 14:05:34 -08:00
patientxandGitHub ae85dc37e6 Enhance requeue functions for ComfyUI version compatibility
Updated requeue_workflow_unchecked and requeue_workflow functions to handle both old and new ComfyUI versions with 5 and 6 value tuples.
2025-11-25 01:15:40 +03:00
应元东andGitHub 8923bd836b fix ffv1 color/alpha shift (#581)
* Add 'gbra' to alpha channel regex check

* Add input color depth 16bit option to ffv1-mkv.json

* use rgba64le by default in ffv1-mkv.json
2025-11-02 23:28:40 -08:00
ed laneandAustinMroz 8550981384 Fix video duration calculation in ffmpeg_frame_generator
Changed hours-to-seconds multiplier from 360 to 3600 when parsing
Duration timestamp. This fixes incorrect duration values for videos.
2025-10-27 08:59:46 -07:00
Hanzo Dev a02bc130f8 Add Hanzo ecosystem README with links and documentation 2025-10-22 14:12:12 -07:00
Christopher AndersonandAustinMroz 6e7f638675 Handle potential undefined displayValue in VHS.core.js 2025-10-16 10:55:35 -07:00
neflowandAustinMroz 08e8df15db Pass frame_rate to gifski -r. 2025-09-28 22:06:44 -07:00
Austin Mroz 0edce8ef7c Add safety check for existance to server endpoints 2025-09-17 18:19:01 -07:00
Austin Mroz 8450df1a8b Allow shift+tab to edit previous widget 2025-09-17 18:19:01 -07:00
Austin Mroz 1355e8c69f Support Animated latent previews on subgraph nodes
Code gets a little messy, but this should handle also handle a
hypothetical future with animated previews for concurrent execution,
while still properly cleaning up after itself once a workflow ceases
execution.

Swapping workflows still causes previews to cease display. I don't know
a clean solution for this.
2025-09-16 23:07:41 -07:00
Austin Mroz dabda5423a Add preview to LoadAudio nodes
Resolves #541
2025-09-07 20:11:47 -07:00
Austin Mroz 2372cebde1 Cleanup app.graph references for subgraph compat 2025-09-07 20:11:47 -07:00
Austin Mroz 8e4d79471b Disable serialization of animated preview widgets 2025-08-07 09:53:50 -05:00
AustinMrozandGitHub f20b356f68 Merge version 1.7.3 2025-08-07 08:57:57 -05:00
Austin Mroz 7e9bea5336 Fix unclosed file handle
The query video endpoint failed to container after completion. This has
been fixed.

Resolves #526
2025-08-07 08:51:49 -05:00
Austin Mroz c017071cc3 Wan22 vae rate info, frontend fixes
Added rate information for the Wan22 vae. This is only used for the 5b
model and the only change is a slight reduction in the preview rate.

Fixed being unable to quickly update multiple frontend widget values
with tab.

Fixed an incorrect default modulus for which forced inconvenient
dimensions for the Load Image node.
2025-08-07 08:40:00 -05:00
Austin Mroz 9cdacf6363 Add test case for loading and saving of audio 2025-07-28 01:44:19 -05:00
Austin Mroz 330bce6c3c Fix misplaced gifski audio workaround
The audio input was changed to be discarded when outputting to the
gifski format since gifs do not support audio and other non-audio
supporting formats will silently ignore audio when it cannot be embedded.
This fix was mistakenly added outside the appropriate conditional block
and instead discarded all audio. This was not intended

Resolves #521
2025-07-28 01:22:38 -05:00
Austin Mroz f1c513a563 Fix node reference missed from refactor 2025-07-26 22:27:57 -05:00
AustinMrozandGitHub 9c5407f2a8 Merge 1.7.0 2025-07-26 15:59:14 -05:00
Austin Mroz eb36470be4 Fix missed step multiplier adjustment
The step calculated from frame rate for start_time on load video ffmpeg
nodes has now been adjusted to not have an unneeded x10 multiplier

TODO: reconsider if this creates overly undesirable breakage if VHS
frontend code is not used.
2025-07-26 15:54:58 -05:00
Austin Mroz 9601d8792e Add bgra as known pixfmt with alpha
Quick workaround for improved compatibility with ffv1
2025-07-26 15:53:25 -05:00
Austin Mroz 97a57e38a8 Round tensors when converting to int
This has been long considered. Special thanks to Mel for providing
additional insight.
2025-07-26 15:49:56 -05:00
Austin Mroz 285568f530 Use libvpx-vp9 for main decode too
An extra pass was added to the ffmpeg_frame_generation code to properly
detect and handle alpha when loading vp9 videos. This early commit
allowed for correct detection, but then failed to apply the decoder to
the main pass, so no alpha was output. This has been fixed.
2025-07-26 15:38:20 -05:00
Austin Mroz be11f43838 Remove global vpx codec context
Since codec contexts have state, reusing them is dangerous.
2025-07-26 15:24:34 -05:00
Austin Mroz 253251fa5c Fix latent preview race cond, subgraphs interop
Since the subgraph utils aren't exposed from core, a small stub function
is just re implemented in VHS.

Execution state seems be robust enough to trust core to clear this
state, but it's initially set to 0 by VHS now to ensure the preview
display doesn't race the message that the node is being processed.

Unfortunately, the lack of ability to determine if a node is actually
running means swapping the active workflow once again breaks the display
of previews. I don't see a way to fix this without changes to core.

While disappointing as a maintainer, the fix never being rolled out
means this isn't a loss of functionality to end users.
2025-07-26 12:20:14 -05:00
Austin Mroz f01332d42c Fix animated latent previews
Resolves #516

This functions so long as multiple nodes aren't providing latent
previews at once. I will need to take time to consider if such
functionality would even be desirable.

Does not resolve subgraphs. Graph traversal functions don't appear to be
exposed, so I'll most likely need to re-implement myself, but I need to
take the time to exhaustively verify first.
2025-07-26 01:38:31 -05:00
Austin Mroz 229642349b Merge FFV1 2025-07-25 01:18:00 -05:00
Austin Mroz 3b2fb5f518 Fix incorrect time base in query video 2025-07-24 22:45:24 -05:00
Austin Mroz a3d66b6736 Merge upload-progress 2025-07-24 21:59:59 -05:00
Austin Mroz 8b62aa458d Ignore audio for gifski output format.
Gifs do not support audio. Rather than crashing, VHS will now ignore any
audio input when outputting to the gifski format. Thanks Mel.
2025-07-24 20:44:21 -05:00
Austin Mroz 7a50ec7ec8 Use PyAV for querrying videos, Round frame est
Prior ffmpeg implementation provided insufficient precision for frame
estimation and was heavier than desired. Regexes are no longer used

Round frame_rate annotation to at most 2 digits
2025-07-23 02:54:49 -05:00
Austin Mroz f76436dcf2 Remove step multiplier, further timestamp fixes
The VHSINT and VHSFLOAT widget constructors failed to multiply the step
by 10. Since this step value is internal to VHS, this is instead fixed
by removing the x10 multiplier entirely from the logic

Integer seconds between 0 and 9 are now correctly padded in timestamps
2025-07-22 01:16:39 -05:00
Hoernchen f1698cbd92 add ffv1 support
FFV1 is a nice lossless format that allows recovering the original images for postprocessing. It is nowadays specified for MP4 containers as well, but ffmpeg support was only added in nov '24: https://git.ffmpeg.org/gitweb/ffmpeg.git/commit/0b8c9cf5b490a0231949ab3c6d797462ae001730 so for now MKV is enough.
2025-07-21 23:03:36 +02:00
Austin Mroz 9e963ff5ed Add progress bar to uploads
Resolves #507
2025-07-13 20:59:47 -05:00
Austin Mroz 266f27aeba Support loading transparent VP9
Resolves #502
2025-07-13 20:03:35 -05:00
Austin Mroz dc98c8be65 Fix hashing nonexistant files, widget rounding
Some nodes "hash" (actually just mtime) input files to determine if
re-execution is needed. A check has been added to not attempt this
hashing if no file exists.

Fix an incorrect bounds check in the newer frontend widget code.
2025-06-22 17:16:31 -05:00
Austin Mroz 66e443b583 Add duration output to LoadAudio nodes.
Resolves #488

Commit to bumping minor version with next update as this change
introduces a new output.
2025-06-12 12:08:36 -05:00
Austin Mroz 66e95e9471 Marginal path display improvements 2025-06-03 15:43:02 -05:00
Austin Mroz 7b26c04c8a Apply Max/Min to VHSINT, initial value in VHSFLOAT 2025-06-03 14:17:35 -05:00
Austin Mroz d7c47abc5e Use 204 for most failed server requests
Previously, detailed response codes were used to indicate what went
wrong on a failed server request. However, this produces un catchable
error messages which are undesirable. Instead, these responses now use
204 to indicate that the request was successfully processed, but returned
no content.
2025-06-02 21:57:48 -05:00
Austin Mroz 688b796e58 Remove inheritance of VHS widgets from builtins
Resolves #484

Fix truncation of trailing 0 when displaying values
2025-06-02 21:24:03 -05:00
Austin Mroz a5946e88a2 More robust animated latent preview tracking 2025-05-23 11:17:49 -05:00
Austin Mroz a20beefa20 Rework widget display to properly measure text
Previously displaying values was a mix of naive guesswork and giving up.
This display process has been rewritten to produce output that is much
more likely to be usable even under tight width restraints.

As part of this, the ordering in which different widget components are
drawn has been reworked.
2025-05-23 01:09:31 -05:00
Austin Mroz 63bfea3d74 Clean up mouse interaction,
Also modifies angle of disable icon
2025-05-22 01:54:41 -05:00
Austin Mroz d5ba30a754 Stroke widget buttons manually
The buttons added to reset to either reset to default value, reset to a
disabled value, or indicate that one of these is currently selected,
displaying poorly depending on availability of system fonts. This is
resolved by stroking the shapes manually.
2025-05-21 17:09:22 -05:00
Austin Mroz 2ab65b9c4c Cleanup timestamp widget conversion code 2025-05-21 16:15:56 -05:00
Austin Mroz c953e12408 Cleanup optional arguments on Load Audio nodes
duration, start_time, and the equivalent seek_seconds are now all marked
as optional inputs and properly set to have default values.
2025-05-21 15:39:08 -05:00
Austin Mroz 6ec6007553 Test fixes.
Auto-close workflow if a test completes successfully.

Add pro-res test

Remove accidental absolute path from test
2025-05-13 12:55:42 -05:00
Austin Mroz 0d5b445a70 Fix connections to format widgets 2025-05-12 15:50:18 -05:00
Austin Mroz 38fbe354b1 Update num_frames when pingpong is applied
Closes #467
2025-05-06 17:23:59 -05:00
Austin Mroz 001a508cb5 Allow drag drop on upload nodes.
Fix out of date IS_CHANGED methods for Load Audio nodes

fix incorrect parens in preview code. The string "0" is truthy.
2025-05-06 16:43:14 -05:00
Austin Mroz a7ce59e381 Fix Use Everywhere compatibility 2025-04-26 15:27:20 -05:00
AustinMrozandGitHub 598e181e97 Merge 1.6.0 2025-04-18 13:54:22 -05:00
Austin Mroz 47fb44708d Fix missing list nesting 2025-04-18 13:46:18 -05:00
Austin Mroz 69ef6d4edc Merge pointer fix 2025-04-18 12:21:44 -05:00
filteredandGitHub 7ed4f6c510 Remove deprecated litegraph param
Unnecessary parameter will be removed in a future release, and brough inline with all other CanvasPointer callbacks.
2025-04-18 21:30:59 +10:00
Austin Mroz 0c0288f9b5 Add duration option to Load Audio (Path)
Functionality already exists in Upload variant and backing util
function, so the change is negligible.

Also commit to minor version bump. Other concommitant changes also break
forwards compatibility, so it's a chance to roll multiple together even
if this bump doesn't accompany anything big or flashy.
2025-04-14 02:32:54 -05:00
Austin Mroz e49fed4585 Variable length format arguements. ProRes fixes
Co-authored-by: Kaur Kuut <strom@nevermore.ee>

A single item list is now mapped as a template string later flattened to a
variable number of arguments. This handles the case of both no and
multiple arguments

Apply required ProRes fixes from xStrom. While the delays were
unfortunate, these changes should handle any future edge cases and
retains support for older workflows using the numbered ProRes profiles

Closes #451
2025-04-13 02:26:26 -05:00
Austin Mroz 053e2beb1d Rework and expand video_format capabilities
Introduces two new dynamic arguments which do not generate new widgets
- ["string"]: string is evaluated as template string with kwargs for
  values. This allows an argument to reference multiple widgets and
  allows multiple arguments to reference a widget
- ["kwarg_key", {}]: This allows for verbose translating of a limited
  set of values.
  - Multiple dicts can be nested and reference different kwargs

Adds an extra_widgets field to video_formats for widgets do nothing
unless referenced by one of the two new argument types.

Pass has_alpha as a kwarg for reference with the new argument types
2025-04-10 00:58:39 -05:00
Austin Mroz 87f73c7dbb Fix misplaced parens
Resolves #460
2025-04-09 13:03:24 -05:00
Austin Mroz 136fc6abde Merge nvenc-av1, pixfmt, prepass fixes 2025-04-08 01:05:27 -05:00
Austin Mroz 82f62dcbf4 Support audio in unbatch node 2025-04-06 11:15:25 -05:00
Kaur Kuut a11bcf4a08 Use correct p010le pixel format with NVENC. 2025-04-06 15:49:15 +03:00
Kaur Kuut 17ad06aa25 Add NVENC AV1 format. 2025-04-06 15:25:09 +03:00
Kaur Kuut 4980590ac1 Fix pre_pass and inputs_main_pass args index. 2025-04-06 14:41:41 +03:00
Austin Mroz df55f01d1d Float rounding fixes
Backend preview code will now accept floats and convert or truncate as
needed.

Fix updateParameter callbacks using out of date, unbounded values
2025-04-05 04:11:07 -05:00
AustinMrozandGitHub 7236c5dff0 Merge 1.5.17 2025-04-03 01:46:54 -05:00
Austin Mroz 2fb06b1634 Merge explicit Metadata, gifski fixes. 2025-04-03 01:45:21 -05:00
Kaur Kuut d1733bb518 Add explicit color space metadata for FFmpeg 4.2 output. 2025-04-03 00:30:23 +03:00
Kaur Kuut a0312ec4c2 Increase the quality of the YUV4MPEG2 pipe stream to Gifski. 2025-04-02 23:47:45 +03:00
Austin Mroz b9366c11bc Swap color range from full to pc, formats
Older ffmpeg versions don't include the full option for color_range.
Documentation indicates that pc resolves to the same value and has been
swapped to.

Resolves #445

The format fix for gifski was improperly applied to all outputs. It has
now been properly restricted.
2025-04-02 14:32:08 -05:00
AustinMrozandGitHub ab96cf2608 Merge version 1.5.15 2025-04-02 11:47:31 -05:00
Austin Mroz f837d6ffdd Force gifski outputs to display as 'image/gif'
When preview data is sent to the fronted, a format field is sent back
to determine how it is displayed. Gifski and ffmpeg-gif formats use
video for the first term since they use the video_formats subsystem, but
this currently results in them being displayed in a <video> tag on the
frontend.

For now, any format using the gifski route has it's format hard coded to
be corrected to gif, but I'm considering a more thorough update in the
future which will use the video_formats subsystem for images as well and
provides a means for formats to them self specify output type.
2025-04-02 11:37:06 -05:00
Austin Mroz 20c4c5a40d Merge gifski resolution fix 2025-04-02 11:00:03 -05:00
Kaur Kuut ccbaf837ad Add support for high resolution gifski encodes. 2025-04-02 16:04:31 +03:00
Austin Mroz eeb794ab0d Merge colorspace, NVENC fixes. 2025-04-02 00:29:14 -05:00
Kaur Kuut d18ff47633 Remove broken rgba support from NVENC formats. 2025-04-02 00:46:12 +03:00
Kaur Kuut b34d3ba46b Add color space metadata to ffmpeg input. 2025-04-01 16:02:32 +03:00
Austin Mroz 972c87da57 Fix incorrect preview logic for gifs
Gifs were incorrectly being loaded by a video tag when advanced previews
was set to "Input Only" (the default). The logic for when advanced
previews are used as been clarified.

Resolves #435

Parameters that determine the output quality when advanced previews are
displayed have been moved to only be set when the request if for an
advanced preview. These were ignored when the request was made to the
default /view endpoint, but created undesirable clutter
2025-03-28 11:32:11 -05:00
Austin Mroz 0a3b1eb433 Improve cloneType disconnection logic
The prior disconnection logic would eagerly disconnect if the type has
changed at all. In addition to providing awful quality of life, this
was also causing issue with workflows that contain an unbatch node being
saved at all.

The code for type cloning has undergone a substantial rewrite to
properly check link validity and to propogate link events

Resolves #432
2025-03-26 12:09:00 -05:00
AustinMrozandGitHub c9dcc3a229 Merge 1.5.13 2025-03-23 22:38:31 -05:00
Austin Mroz d3e3c2e6b4 Merge removal of native metadata parsing 2025-03-23 22:33:10 -05:00
Austin Mroz 546359d436 Add additional null check to audio link migration 2025-03-23 22:32:20 -05:00
Austin Mroz 0d5870a4ce Fix padding of seconds term 2025-03-23 22:32:00 -05:00
AustinMrozandGitHub 5e61bcf218 Merge version 1.5.12 2025-03-19 16:16:33 -05:00
Austin Mroz c338a1e572 Display frame rate annotation when force_rate is 0 2025-03-19 16:00:12 -05:00
Austin Mroz bbac2480f7 Add lossless toggle to image/webp output format
Resolves #425
2025-03-19 08:56:26 -05:00
Austin Mroz 2fbbd53e62 Fix incorrect seconds padding, remove callback 2025-03-18 17:42:51 -05:00
Austin Mroz 93ae745f22 Add timestamp widget
Several nodes, like Load Video FFmpeg and Load Audio have a widgets that
take a time in seconds.

These are now handled by an updated widget that will display times as
hour:minutes:seconds and allow entry by hour:minutes:seconds
2025-03-18 17:25:40 -05:00
Austin Mroz e661ee7f3f Set start_time step to be single frame.
This is simple and low cost to implement since video information is
already being queried.
2025-03-18 12:59:01 -05:00
Austin Mroz 63ac6727c9 Accept all inputs instead of passing prompt
Having dicts that lie about contents has been officially sanctioned as
the supported way of allowing dynamic inputs, but VHS already had
functioning and well tested code for parsing PROMPT directly.

Since there are issues with Video Combine being used inside dynamic
prompts, it is better to swap to the intentionally supported method.

Resolves #424
2025-03-17 15:59:20 -05:00
Austin Mroz 718af34bc2 Use setWidgetConfig on options
SetWidgetConfig is usable on widget options despite the type mismatch.
This provides a cleaner solution than a blind
Object.getOwnPropertySymbols call.
2025-03-17 15:57:57 -05:00
AustinMrozandGitHub 0a75c7958f Merge 1.5.11 2025-03-14 10:45:31 -05:00
Austin Mroz 3b36daba92 Image format progress bar, lossless webp output
PIL output formats were already passed iterators for improved memory
management over pre-collected lists. Adding a progress bar on top of
this is simple QOL.

The image/webp output is now set to always be lossless. This is
potentially owrht exposing in the future, but I assume lossless will be
preferable the overwhelming majority of the time.

Resolves #405
2025-03-14 02:28:56 -05:00
Austin Mroz 53c406679d Use node size to calculate target preview width
The prior code of parsing width traces all the way back to f63a0f78

I no longer recall the purpose of the slice, but parsing element
information to determine sizing is inherently flawed. An element width
will not exist until a preview has loaded. Instead, the element size is
back calculated from the node size.
2025-03-14 02:28:53 -05:00
Austin Mroz e44b3565c7 Allow dragging node from VHS preview widgets
See #414
2025-03-14 02:28:49 -05:00
niboshi 2cf03c6ac0 Fix color degradation in ffmpeg frame generator 2025-03-14 04:40:28 +09:00
Austin Mroz 73418575ca Fix low quality rendering threshold
The prior fix to respect rendering threshold had a number of mistakes.
Foremost, a typo in naming prevented it from applying at all. Path
widgets perform rendering separately and need an independent fix. The
comparison has additionally been changed to greater than or equals. This
is most noticeable when low_quality_zoom_threshold is set to 1.0

See #412
2025-03-11 16:44:15 -05:00
TommieandAustinMroz ea525bfbd6 Fix input mime type for Matroska files
Fixes .mkv files not showing in the file upload window
2025-03-11 16:24:04 -05:00
Austin Mroz 28eb44c13c Pass mouse events for animated latent previews
Resolves #414
2025-03-11 14:45:59 -05:00
Austin Mroz 1ad84c1a11 Restore input validation for load_video_nodes
About a year and a half ago, ComfyUI changed it's input validation to
allow for specifying which inputs should be validated, but removing the
undesired inputs would cause errors for users who had not updated
ComfyUI to recieve this update. As a result, input validation emthods
were modified to accept an additional kwargs input to allow
compatibility with both the new and old versions. Around the time
execution inversion landed, this was again changed so that a kwargs
input could be used for validation with dynamic inputs. From this point,
the kwargs argument also served to prevent input validation.

Supporting extremely old (>1 year) ComfyUI versions is not currently a
major priority, so these kwargs inputs can be removed to restore input
validation

Resolves #413
2025-03-11 14:45:59 -05:00
Austin Mroz 25c31ca79e Respect low quality rendering threshold
Resolves #412
2025-03-11 14:45:59 -05:00
Kaur KuutandAustinMroz 031c4ae301 Fix query_video always raising an exception for WebP. 2025-03-09 21:35:39 -05:00
Austin Mroz 3a96e87951 Suggest a frames_per_batch on format mismatch
Supporting formats on Meta Batches brings back an ugly problem of
expecting users to do math. Restricting selections on the Meta Batch
node would be an onerous amount of work, but as a simple solution, the
closest valid frame is suggested when an error occurs
2025-03-06 11:04:27 -06:00
Austin Mroz 7e3eb2a62e Fix meta batch with format, remove wan factors
ComfyUI has added native latent factors for wan, so the ad hoc and
particularly unhelpful factors I made can be pruned.

Fix a missed scoping when checking that meta batch is compatible with a
selected load format. Special thanks to tonirv68.

This is still a bit clunkier than I would like and will likely see
future updates.

Resolves #409
2025-03-06 11:04:27 -06:00
Austin Mroz 626cb249d1 Remove parsing of native video metadata
Core has staged a more robust implementation for parsing video metadata.

Once this been rolled out, it should be used instead of the `good
enough` implementation used by VHS.
2025-02-28 17:23:01 -06:00
Austin Mroz 4c7858ddd5 Check for existance of Wan21 format first
I was a little over eager with injecting latent factors for Wan. If an
older version of comfy is used, it will no longer cause VHS to fail to
import.
2025-02-25 18:19:59 -06:00
Austin Mroz 51aa43368d Add documentation for Select Latest, prune timeout
The code for querying video information is an async function. Rather
than adding a setTimeout call, the function is simply called, but never
awaited.

Remove experimental from unbatch description
2025-02-25 17:26:46 -06:00
Austin Mroz b601e8cf61 Add format information for wan
Latent factors have minimal testing and are probably bad.
2025-02-25 17:26:46 -06:00
Austin Mroz 8343122234 Reduce web console spam
Query video isn't denounced and winds up being checked frequently. This
can produce heavy console spam when source information is frequently
updated. More cleanup will be done, but for now, an empty response is
returned instead of an error to prevent the console from getting
spammed.
2025-02-25 12:24:12 -06:00
Austin Mroz 532de499f6 Select Latest poc
Adds a new node to select the most recently modified file of a target
folder. This can be used to automate using the output of one execution
as the input for another. In order to allow frontend values to properly
update, this is implemented as a virtual node, skeleton node def is
included due to difficulty with setting up the node as fully virtual.
This will likely be revisited in the future.

Add an optional length argument to path trimming. The original 30
character length estimate seems to no longer be accurate which results
in text overlapping. A more thorough investigation and fix will come
later.

Update experimental designation on nodes.
2025-02-25 12:24:12 -06:00
Austin Mroz 23ebe28091 Fix persisting output previews across reloads
Preview updates on message. This nonsense of wrapping outputs is not
useful.
2025-02-25 12:24:12 -06:00
Austin Mroz 2c25b8b538 Minor cleanup
video_query data is deleted when a new preview is loaded. This ensures
old query information doesn't persist on failed load.

Call onRemove on removed output format widgets. This doesn't matter for
any existing formats, but swapping to app.widgets allows for arbitrary
widgets to be included in formats which may require it.

Remove the app argument from the VHSFLOAT and VHSINT widget
constructors. These constructors aren't actually used, but this change
brings there functionality closer in line with the upstream sentiment.
2025-02-24 12:06:27 -06:00
Austin Mroz 514de7fe2e Add video source information to context menu
Inspired by the change to core image preview nodes. Information for a
video source is now displayed in the context menu for nodes with a
preview.
2025-02-24 12:06:27 -06:00
Austin Mroz f2c905aaed Make numeric format widgets annotated, tab safety
Format widgets which are numbers are now converted to be annotated
widgets. This ensures they have full access to VHS QoL in the future
even though none can make use of this functionality at present.

Sequential widget prompting with now check that the subsequent widget
has a mouse function before blindly calling, but is also lmited to only
annotated numbers for now. The distinction makes no difference for
existing nodes, but adds an additional layer of safety.
2025-02-24 12:06:27 -06:00
Austin Mroz 127b3646fa Allow sequential widget updates with tab
When a widget is clicked on a dialogue pops up to allow a value to be
typed. For numeric inputs on VHS nodes, pushing tab will now finalize
the input and create a new prompt for the next widget on the node.

Fix incorrect indentation.
2025-02-24 12:06:27 -06:00
Austin Mroz 9e0590cfdb Minor missed changes to comments 2025-02-24 12:06:27 -06:00
Austin Mroz 124c913ccd Fix loading of mp4 metadata
mp4 files are parsed separately from webm files. The prior commit
changed the parsing to directly pass the workflow content, but failed to
make an equivalent change in the mp4 parsing code.

As a fix, the full dict containing metadata information is now returned
as before and native videos are simply wrapped.

Resolves #393
2025-02-21 12:33:02 -06:00
Austin Mroz 8262bb941d Support Core format for video metadata
Core has added experimental support for outputting video, but does not
support loading it yet. VHS currently swallows any video files it can
not find metadata in. Once ComfyUI adds support for loading it's own
video files, this will result in an awkward period where VHS breaks
loading of core ComfyUI video files.

Instead, I've just implemented support for loading workflows from videos
output by ComfyUI.
2025-02-21 07:49:54 -06:00
AustinMrozandGitHub c36626c602 Merge version 1.5.2
video_format migration and input caching
2025-02-16 11:58:00 -06:00
Austin Mroz c81556ac3c Lazy load documentation images. 2025-02-15 23:05:43 -06:00
Austin Mroz 24566553a5 Remove injectHidden
injectHidden was previously used to hide the custom width and custom
height widgets when they had been disabled by force_size.

As this functionality has been removed, this is dead code and can be
removed as well.
2025-02-15 17:04:23 -06:00
Austin Mroz 082b2007f0 Remove VALIDATE_INPUTS from Video Combine
Since formats no longer has funky item renaming to transfer format
widgets, the builtin validation no longer needs to be disabled and the
VALIDATE_INPUTS method can be removed
2025-02-13 17:55:03 -06:00
Austin Mroz 8ffcde28b2 Cache video formats
Calls to INPUT_TYPES are frequent, which results in many disk reads. The
output of INPUT_TYPES is now cached for the last 5 seconds. This should
fill a nice middle ground between video formats being updated for
changes on disk and the current state of requiring 30+ disk reads on
prompt queue.
2025-02-13 17:38:00 -06:00
Austin Mroz e8ad2a851d Cleanup Format Widget js
Now that passing of information has been simplified, it's handling can
also be cleaned up. The new code should have no functional difference
but is vastly shorter.

Because the functionality has not changed, this still does not require a
reload.
2025-02-13 16:36:43 -06:00
Austin Mroz 0963fa917f Part 2 of format migration
Format widgets are now stored in the options of format widget instead of
the name.

This code has been prepped to execute without requiring a webpage reload
unless a user has not updated in the week prior to this commit.

A minor js change is made to allow for a change in format to function
without recreated nodes, but this will be phased out immeidately now
that the code for format selection can be simplified

See ffbc27304
2025-02-13 16:02:09 -06:00
Austin Mroz 8629188458 folder_paths fixes
Fix misnamed variable for external formats.

Properly set extension when extra model paths are specified.

Don't append extension name.

See #379
2025-02-08 02:09:16 -06:00
Austin Mroz 3bfbd99ffc Fix options lookup when extra_pnginfo is None
Resolves #377
2025-02-07 03:02:50 -06:00
Austin Mroz 701f7e7c59 Prioritize builtin video_formats:
Several other custom nodes have blindly copied VHS code and overwrite
the registered location for video formats. To solve this, the builtin
video_formats folder is checked separately and given priority over
folder_paths formats.

See #375

Bump version
2025-02-07 01:08:20 -06:00
Austin Mroz f470aeb38e Fixes to format widget transition code.
A couple bugs were found when actually testing with both the new and old
methods. Both methods have now been verified as functional
2025-02-07 01:08:20 -06:00
Austin Mroz 7895509345 Make server subprocess calls async.
Despite initial best efforts, the subprocess code for preview
transcoding causes frequent issues with blocking the server thread.
Instead, the asyncio methods for subprocesses are now used instead.
2025-02-07 01:08:20 -06:00
Austin Mroz ffbc273042 Partial migration for format widgets
When format widgets were initially introduced, node defs were only
partially updated when a refresh node def operation was performed. As a
workaround, format widgets include this additional information in names
of the selections. This less than desirable and should be phased out.

As every single update has had users that fail to reload the page and
run old javascript, I plan to release this change in three phases.
- (this commit) Check for format widget definitions in format widget
  options as well.
- Modify the format widget defs to be included in the options instead of
  names
- Only check for format widget defs in options and remove the code for
  parsing names
2025-02-07 01:08:20 -06:00
Austin Mroz 86e9f6a4cb Add options to remove additional file outputs
Closes #305
2025-02-07 01:08:20 -06:00
Austin Mroz f736938962 Fix errant request reference
Path calculation was moved into a subroutine that recieves query as an
argument, but a reference was missed in refactoring

Resolves #371
2025-01-31 15:17:39 -06:00
AustinMrozandGitHub 5d0c595df3 Merge pull request #370 from Kosinkadink/develop
Merge 1.5.0
2025-01-30 22:07:27 -06:00
Austin Mroz 34daa91e0e Update documentation. 2025-01-30 19:36:09 -06:00
Austin Mroz 0a4462bb1e Display frame count regardless of format
When a format like AnimateDiff or None is selected that do not put
restrictions on frames is used, it is still useful to see the number of
frames available

NOTE: the number of frames will go negative if skip_first_frames or
start time is too high. It's easy to restrict this to 0, but I'm leaving
it in for now as I consider it useful information.
2025-01-30 19:11:37 -06:00
Austin Mroz 38927c5f46 Add deadline option for advanced previews 2025-01-30 17:08:49 -06:00
Austin Mroz 7141535f22 Loosen resolution restrictions
Instead of assuming a downscale_ratio of 8, the format settings for
dimensions are used when no vae is connected. This means that no
restrictions are placed on resolution when the None format is used.

The default dim alignment has been lowered from 8 to 2. I can no longer
replicate issues with dim alignments that aren't a multiple of 4. If
others have issues, it's simple to raise this on a per format basis in
the future.
2025-01-30 16:56:00 -06:00
Austin Mroz 903ecc3a97 start_time precision, vae inputs
While 1 second is a reasonable step for start time, this setting was
causing undesiable rounding and truncating of precision. The precision
has been manually increased to 3 decimal places while maining the 1
second step

The vae input was broken for the ffmpeg nodes and the Load Image (path)
nodes. basic operation is fixed, but the mask output is currently
disabled while in use and will require further attention.
2025-01-30 16:18:20 -06:00
Austin Mroz 76d9fe74f3 Initialize video location callback sooner.
Since the Path and Upload nodes have different processing needs for
source selection, this isn't handled in the common load video code.
Previously, this code was executed after which meant that the initial
state isn't recorded when the common load code triggers callbacks for
each widget.
2025-01-30 13:48:23 -06:00
Austin Mroz b212f7a3bd Calculate loaded information in queryvideo
Generating accurate frame counts becomes fairly involved when other
widgets are modified that affect frame number. In an effort to
centralize this frame count logic, the loaded number of frames is now
calculated on the backed queryvideo call and this number is then
displayed on in the annotation.

Add try/catch loop to the queryvideo call. This failing is expected if
the ComfyUI server is restarting or down and this ensures exception
doesn't propagate and cause issues elsewhere
2025-01-30 13:02:36 -06:00
Austin Mroz 338c3ef61e Fix backend format application
Fixed performing checks on format name instead of the actual format.

Add safety slice to the div/mod destructuring in case the third strict
arg is provided.

Remove the log message. The number of frames displaying on the widget
itself is a better pathway for informing the user than cluttering the
console
2025-01-29 23:55:12 -06:00
Austin Mroz bc5c3d134b Move external formats to nodes, update dims, merge
External load formats were checked for in utils.extra_config. While this
is a logical place for them to exist, the small size of the file gives
me concern that it could moved or refactored at some point. Instead,
this is now checked fro in nodes

Most of the video formats have dimension restrictions other than 8. Each
has now been updated accordingly.

Merged from origin to resolve the merge conflict locally.
2025-01-29 17:24:35 -06:00
Austin Mroz 5d98b154a8 Restore the initial callback for Load Video
Nodes that are added, rather than being restored do not have configure
called and thus do not have their callbacks triggered. The addLoadCommon
method used to initialize the Load Video nodes will now explicitly
trigger the callback on every non video widget at the end to ensure it's
done in a unified singular spot.
2025-01-29 16:09:59 -06:00
Austin Mroz 17fc1b9b67 Add annotation if frame_load_cap > loadable
The frame_load_cap widget will now display the actual number of frames
that will be loaded if it differs from the selection

The queryvideo endpoint now wraps its information under a source item.
If information is later added to process other query args, those may can
now be added under a loaded item instead.
2025-01-29 15:54:06 -06:00
Austin Mroz c2449e03dd Add default format resolutions, check force_size
Add default resolutions to each of the load video formats and added
shorhand code for assigning them

force_size is now checked for and a warning is displayed if it is used.
Users not reloading the webpage after installing an update is a
recurring issue.
2025-01-28 14:23:49 -06:00
Austin Mroz d03988238d Add query video path, Error on invalid length
A queryvideo endpoint has been added to fetch information about a video
file including resolution, duration, and number of frames. This does not
currently process any query args, so number of frames will need to be
sacaled for target fps.

Add a option to do strict checking on the number of frames and raise an
exception if this is not met. If strict checking is not enabled for a
format, log an error instead. This is subject to change since runtime
exceptions are heavy handed even when opted into.

Modify the ffmpeg output parsing for fps to be safer.

When downloading previews with yt-dl, do not block
2025-01-28 13:45:11 -06:00
Jedrzej KosinskiandGitHub 565208bfe0 Merge pull request #367 from ComfyNodePRs/update-publish-yaml
Update Github Action for Publishing to Comfy Registry
2025-01-28 00:33:29 -06:00
Austin Mroz a5e362b87e Fix Load formats, allow 0 length cap, add disable
A previous attempt to update the load format code to prevent defaults
from being clobbered was mistakenly added to the wrong function. This
has been fixed.

The callback to restrict widget values to number + modulus now
explicitly always allows 0

frame_load_cap now has an added annotation to disable
2025-01-27 18:36:34 -06:00
Austin Mroz 1b35b85f95 Move initial value callabcks to KVState
Prior commit was flawed, a node is not configured until after all
onNodeCreated fire. Performing callbacks prior to this moment is wasted.

Instead, the KV state code performs this callback but with an added
guard to ensure that buttons aren't clicked.
2025-01-27 18:08:45 -06:00
Austin Mroz 616577a0bd Improve callback ordering
The ordering for adding callbacks for node functionality was ill thought
out. This both made initial loads unreliable and resulted in many places
in the code were unnecessary delays were added with
requestAnimationFrame or setTimeout.

Remove the callback from key/value setup. This was causing buttons to be
clicked and was merely masking issues elsewhere.

Refactor _addVideoPreview back in. The video preview code was briefly
used for the implementation of latent previews, but the released code
uses images and the separation no longer has purpose.
2025-01-27 17:29:05 -06:00
Austin Mroz 460c90afd1 Don't remap image load args in preview requests
Functioning server code was in place to pull image_load_cap and
skip_first_images when processing previews in the Load Images nodes, but
these args where being redirected to function on the images after they
have been converted into a video. This was causing issues, so this
remapping has been disabled.

Resolves #368

Added a check to ensure a format input exists on load nodes before
trying to initialize it.
2025-01-27 00:23:16 -06:00
Austin Mroz 17abd98d99 Fix type checking for FLOAT or INT inputs
Modify utility class for these multi inputs to support an allowed types
list argument
2025-01-26 15:20:57 -06:00
Austin Mroz b85ead786c Fix annotated number conversion
The type of an annotated widget has been changed to BOOLEAN so that it
isn't a known Litegraph type, but is still a valid type for widget
conversion. This will require further testing to ensure it doesn't break
anything.

Annotated numbers converted from floats can now connect to both INT and
FLOAT types.
See #365

Fixed a bug where Load Formats prevented a node from being cloned

Known issue: Cloning a Load Video (Upload) node with alt+drag seemingly
triggers the file upload dialogue.
2025-01-25 15:31:37 -06:00
Austin Mroz 775b931f64 Load format isolation, aspect ratio tracking
Load formats provided by VHS are now stored separate from those other
extensions can potentially provide. This ensures the formats built in to
VHS can't get clobbered by other devs blindly copying VHS code.

Aspect ratio is again tracked to determine when previews need to be
updated.

Further input tweaks: The 8x step has been removed from dimensions so a
user can opt into other dimensions when no format is selected.
frame_load_cap is now marked as being disabled when set to 0.

A request animation call used to initialize state after widget values
are set has been changed to a timeout. There's some unreliability here
which warrants further investigation, but this is much more reliable for
the interim.
2025-01-25 04:49:21 -06:00
Austin Mroz 2498563e23 Rollback source element usage
HTML source elements claim to provide a way to have different priorities
for videos served. I had hoped that this would provide a way to minimize
the chances that a users configuration would result in previews failing
to display, but from testing, this appears to be quite unreliable.
updating the src on source elements doesn't cause the browser to
recheck which source items are valid and and I noticed requests
persisting after elements have been removed.

Since this update set is already becoming larger than expected, I plan
to revisit this functionality at a later date.

Functionality to skip encoding in the video preview endpoint has been
reduced. This functionality created security concerns by exposing read
access to any file on the users system. Instead, it only displays files
that already can be handled by the native view endpoint and only
executes in the fringe case that a user install is incomplete and ffmpeg
is unavailable.

Further old code related to force_size has been removed. The viewvideo
endpoint will likely be changed in the future as well, but I'm more
cautious of other custom nodes that leverage this endpoint.
2025-01-24 13:22:55 -06:00
Austin Mroz 98e651bc91 Load format fixes
Always trigger callback when first loading widget values

Fixes to translating of convenience format options.

Add additional annotations to load types and fix indentation.

Properly apply rounding with modulus on annotated widgets.
2025-01-24 00:51:09 -06:00
Austin Mroz 9a1ff45106 Partial formats implementation, remove force_size 2025-01-23 14:19:56 -06:00
Austin Mroz f3f78c4074 Migrate annotation code
Fix removed addvideopreviewcall

Add initial LoadFormat data. Default/None will likely be generated on
node def registration and others will be defined on backend, but
exposing formats is still planned so that other custom nodes can add
onto it. Requiring external custom nodes configure on JS is ugly,
consider choosing a central ComfyUI location to inject formats to since
dependencies are non viable?
2025-01-21 18:44:52 -06:00
Austin Mroz f24f4e10f4 Fix latent preview downscaling.
By setting the downscale cutoff for latent previews to 512, it wound up
never getting tested. Animate diff has a target resolution of 512 and
newer video models only have conversion with latent2rgb,

The code for downscaling preview frames that are greater than 512 has
now been fixed and tested.

Resolves #361
2025-01-21 11:22:39 -06:00
Austin Mroz a54edeaf7b Add multiple sources with ordered priority
Users have had issues with both states for advanced previews failing to
display video information. When off, some codecs like h265 will fail.
With it on, there have been reports of previews never displaying.

Now the other state is set up as a fallback to maximize the chance
somethign displays regardless of user setting.

From testing, this current implementation seems insufficient. It seems
that sources are not rechecked after a video has properly loaded. This
will need further investigation.
2025-01-20 18:16:08 -06:00
snomiao 8e1ccb21f6 chore(publish): update GitHub Actions workflow for node publishing
- Add permissions for issue writing
- Update action version to v1 for publish-node-action
- Add condition to run job only for 'Kosinkadink' repository owner
2025-01-20 21:24:22 +00:00
Austin Mroz 48e7f70418 Add Input Only option for previews as new default
Use new format to define settings as part of the extension

Update calls to get setting value to no longer provide a default value,
This was once required, but is now deprecated and produces console spam.

Introduce a skip encode parameter which returns the file itself. This
allows the preview endpoint to be used to reference video files by full
path, without requiring re-encoding

Add a minimum width setting.
2025-01-15 16:06:42 -06:00
Austin Mroz c47b10ca17 Add rate information for Cosmos previews
At the time of this commit, latent factors have not been released for
cosmos yet. Previews will not function until they are
2025-01-10 13:13:09 -06:00
jpgallegoarandAustinMroz c5216a51c0 Fixed latent preview for python < 3.11 2025-01-06 17:44:25 -06:00
AustinMrozandGitHub 78753dbbdb Merge pull request #352 from Kosinkadink/develop
Merge 1.4.5
2025-01-03 14:35:26 -06:00
Austin Mroz 17f25aec65 Merge fix_load_image_path
Scrunch matching for video info back to a single pass.

The default used when fps is not found is kept at the suggested 1, but
the preview endpoint uses a default of 30 fps. These should probably be
made equal at some point in the future.

Bump version
2025-01-03 14:31:34 -06:00
Anton 875febd153 Fixed the load image (Path) that was failing 2025-01-03 13:11:23 +00:00
Austin Mroz 3ed5c7fcc2 Remove colorspace filter from ProRes/webm formats
Applying this breaks alpha and testing seems to indicate that the other
colorspace arguments produce a much more visible difference in output
quality.

Resolves #350
2025-01-03 01:28:42 -06:00
Austin Mroz 23d3de9084 Enable AdvancedPreviews by default
As the issue of properly streaming previews has been resolved and the
feature has had ample testing, I'm swapping it it to be enabled by
default. This makes sure a user who is installing VHS for the first time
gets the best possible experience without the need to manually tweak
settings
2024-12-29 15:37:19 -06:00
Austin Mroz cad87a17a3 Prevent error on load
See #347
2024-12-25 13:29:04 -06:00
Austin Mroz 5f118cc5b8 Fix single image preview 2024-12-25 13:29:04 -06:00
Ikko Eltociear AshimineandAustinMroz 9699e3e8cd docs: update README.md
ouput -> output
2024-12-25 12:46:55 -06:00
AustinMrozandGitHub 3d584a1996 Merge pull request #343 from Kosinkadink/develop
Version 1.4.3
2024-12-21 12:47:18 -06:00
Austin Mroz 9951b2447d Merge onto main, bump version 2024-12-21 12:44:37 -06:00
Austin Mroz 42f52a6a91 Always use default when a format widget is missing 2024-12-21 12:24:10 -06:00
Austin Mroz 43ac76cffb Default preview rates
Target latent frame rates have been pulled from core example workflows
and details provided in sd.py. Playback has been changed to rate
override and the default rate changes per model when this is left to 0.

The tooltip for this setting has been update to be accurate to it's new
function.

An additional minor tweak has been made to the rate at which videos are
served. The starting time is now set to be 1 frame behind the current
generation. This reduces the likelihood of too fast generations causing
a frame to be wrap around and served early.
2024-12-21 01:08:46 -06:00
Austin Mroz 4a72c8354d Set preview canvas to preview size
Scaling the canvas to node size is unhelpful. The element itself is
already being scaled in the DOMWidget code, so this just creates
additional distortion by performing the scale operation multiple times.

Instead, the canvas is set to precisely the size of the preview which is
to be displayed to ensure that only one scale occurs (on the front end)
2024-12-19 22:27:04 -06:00
Austin Mroz d54eed673a Use canvas for preview display.
Yet another tweak substantial change to front end preview display.
Using image tags requires the blob be converted to an objectURL, but
this clutters up the requests pane of browsers and creates a risk of
memory leaks.
2024-12-18 22:39:41 -06:00
Austin Mroz 7036292563 Workaround for race condition
The base implementation for previewing attempts to perform a nonblocking
`to` operation. This results in a race condition where the operation may
not have completed by the immediate next line. When this occurs, the
created image will be of uninitialized memory. Frequently a previously
processed preview (sometimes even from a previous workflow execution) or
zeros (resulting in a black image)

Initial testing shows that simplifying making this operation blocking
resulted in a significant performance hit. Instead, the decoding and
processing has been moved to a separate thread. My understanding is that
this makes the preview processing functionaly asynchronous because
blocked torch operation release the GIL, but initial investigations show
a ~10% slowdown to total execution time. This suggest minimal
improvement over simply making the original `to` operation blocking.

If this approach proves stable and no or minimal cost as intended, I'll
look into upstreaming the fix at a later time.
2024-12-18 12:50:03 -06:00
Austin Mroz ccde0f2014 Add preview rate option.
After accounting for the loss of in-between frames from their temporal
vae's, both mochi and ltxv have a target frame rate that is close to 8.
Given the difficulty of conveying the discrepancy of in-between frames
and the hard diminishing returns of processing too many previews on
execution rate, it was very tempting to leave this option off entirely.

I have decided to include anyways under the consideration that it would
be easier to explain to a user why changing a setting didn't work the
way they expect than to explain why the setting doesn't exist at all.
2024-12-17 02:57:36 -06:00
Austin Mroz 25eeb7168c Cleanup Preview widget if previews disabled
A VHS videopreview widget is added to any sampler node when needed to
display animated previews. If a user disables the setting to use have
VHS produce animated previews, this widget is now properly cleaned up
and removed.

As a minor side note, there is not a sane way to disable serialization
of widget data in a saved workflow on nodes outside VHS that do not use
our key/value system. As a  result, any workflows saved include will
zombie data for this added preview widget which may clobber over
defaults if a new widget is added to any of the sampler nodes this can
apply to. Given that adding key/value support to core has been floated
and I consider the chances of new widgets being added to the core
sampling nodes slim, I'm not terribly worried.
2024-12-12 14:55:04 -06:00
Austin Mroz 6953fa2144 Fixes to trimming, add to formats
The js code to add format widgets incorrectly checked for truthiness,
so trying to set false as a default value would leave a widget
uninitialized. This has been fixed.

The check for the actual state of trim_to_audio incorrectly assumed it
to still be a boolean and has been corrected.

The webm and h264 formats have had widgets added for trimming to audio
length. After further consideration, VHS already has code to ensure that
workflows won't brick if an option is removed in the future. A minor QoL
being sunset in the future is still better than that QoL feature never
existing.

Bump version
2024-12-12 11:47:44 -06:00
Austin Mroz e5d8fd3033 Add support for trimming video to audio length
Until now, video outputs that include audio would determine duration
solely off of the input frames. As the image content has been more
important until now, it was preferable to ensure that image frames
would be discarded, even if there's a slight disagreement in audio
length.

mmaudio will frequently produce audio that is shorter than the number of
input frames. Under the existing system, this results in an awkward
silence at the end of videos. This commit adds support for a format
flag, trim_to_audio, which, if true, will cause these excess video
frames to instead be discarded.

This does not add the setting to any video files, as doing so would then
cause permanent setting bloat even if this issue with audio dead time is
resolved in the future or not present on future audio architectures.
2024-12-12 11:47:44 -06:00
Austin Mroz bdcb974a6e Hook cleanup, polish
The existing hooking methodoloy was a little to unwieldy for my taste.
Implementation has been moved to a decorator that uses functools.
Additionally, args and kwargs are added as a best effort attempt to
futureproof

Use to_bytes instead of raw binary for event and target. This makes the
purpose marginally clearer.
2024-12-11 23:29:34 -06:00
Austin Mroz 84b469a691 Refactor previewer as class, reimplement setting 2024-12-11 15:18:38 -06:00
Austin Mroz 2d8feb1019 Better calculate what previews should be sent.
While displaying previews, it's pointless to send images that will be
overwritten before they can be displayed in browser. The logic has been
improved to limit the number of the latents sent to the number of
latents consumed during the current sampling step.

Rescaling of previews is properly performed. Prior explorations made
varying aspect ratio or resolution difficult, but the current
implementation handles changes well and the scaling logic can be moved
to properly perform downscaling.

Prevent memory leak by properly destroying frames when a new execution
is started.
2024-12-11 01:09:05 -06:00
Austin Mroz 0eeb3a4a8d Use VHS previewWidget for image display
This solves the issue of flickering. Since binary messages must match
the format of the existing still iamge previews, careful ordering is
required to make sure that any messages are properly intercepted and
propagation is candled before an await occurs.

The large number of network requests remains annoying, but performance
seems otherwise superior now to the video approach.
2024-12-10 15:49:12 -06:00
Austin Mroz 24de9ef249 Attempted rewrite to send images instead of video
This simplifies the program flow and allows for removal of buffering,
but seems less stable and result in network request spam.
2024-12-09 19:20:50 -06:00
Austin Mroz 0d44f4b46d Add support for copying the output filepath
On right click, Video Combine nodes now have an option to copy the
output filepath. Additionally, if a paste operation is performed
afterwards, a Load Video (Path) node is automatically created populated
with the copied file path.

From my testing, it's not possible to store an indicator to the
clipboard (even with web types) to convey in the clipboard that this
data was created by a VHS node, so unfortunately, this node creation
capability will not persist across reloads.
2024-12-09 01:21:24 -06:00
Austin Mroz bdd98085ad Preview context menu improvements
The Save and Open options are no longer displayed when the original file
can not be served.

A new option to download the first frame png has been added.
2024-12-06 12:58:21 -06:00
Austin Mroz eb39ab47cf Fix missing time import
Based off documentation, a read operation on a bufferedreader may return
None if no data is available. This was handled by sleeping fora short
duration and retrying, but this code was non functional due to a lack of
a time import.

While the lack of a raised issue is likely indication that the check is
dead code and can be removed, simply adding the import is the safest
option.
2024-12-06 12:58:21 -06:00
Austin Mroz 97e8e2ebed Support temporal vae, add front end setting
Default value on settings still isn't quite applying correctly,
fallbacks are in place elsewhere, but will need to dig further
2024-12-05 00:57:12 -06:00
Austin Mroz a9d08964ad Migrate latent preview code, embed in KSampler
Latent video previews are now properly supported on the frontend without
the need for making manual changes.

Further testing/polish is still needed.
2024-12-04 20:00:00 -06:00
Austin Mroz 2b15c4f03c Initial POC for generating animated latent preview
Needs a hefty coat of polish, but it's sufficient to prove viability and
provide confirmation on performance characteristics.

latent2rgb has negligable performance impact, taesd is about 15%
overhead on my system.
2024-12-04 01:28:45 -06:00
Austin Mroz f4b4764694 Add async sleep to preview gen, kill in finally
With the fix to make read non-blocking, the preview code would busy
loop. This made concurrent display of previews non-functional and had
poor performance.

To handle this, the read has been given a larger buffer (1MB) which is
unlikely to saturate, a miinimum loop duration is added of .1 second,
and the fallback sleep (which is unlikely to ever be called) is made
async as well.

The previous except cases were insufficient. For example, if the ComfyUI
server was killed while a preview is being produced, then a BrokenPipe
error message is displayed.
2024-12-03 17:43:57 -06:00
Austin Mroz c8ec04b782 Fix video preview streaming.
Sometimes hours of hairpulling end in a trivial solution.

Because a maximum size was not specified for the read operation, the
read operation was blocking until ffmpeg had finished producing the
entirety of its output. By specifying an arbitrary, (but fairly small)
size, video files now properly stream and the delay between a preview
being requested and being first display is both significantly reduced
and no longer scales with the length of the previewed video.

I had thought I had tested for this specifically and found that external
media players would properly stream previews, but I am regardless glad
to have things functioning as designed.
2024-12-03 17:43:57 -06:00
Austin Mroz 05f085e97a Properly handle format widget conversion
Format widgets don't exist in the node def. This makes converting them
into an input difficult. AddInput is now wrapped on nodes that include
format widgets (only Video Combine) to intercept and add the proper
configuration to the input on conversion. Separately, when workflow data
is loaded that includes a converted format widget, that config data is
properly applied to the already existent input.

Code to forcibly convert input types which do not match has been
removed. This code was a best effort attempt to ease over the transition
from VHS_AUDIO to AUDIO, but did not function as well as hoped and now
causes more issues than it solves. Particular by clobbering a converted
input type to wildcard when connected to a primitive.

Resolves #326
2024-12-01 00:05:27 -06:00
Austin Mroz 82229859fe Initial support for converted format widgets
Format widgets were implemented by pulling information from the prompt,
but this is non functional when an input is resolved at runtime. By
adding a kwargs input to node function, only these converted inputs are
passed. As a quick solution, any passed kwarg value overwrites any
prompt provided kwarg value.

See #326

Update version, this had been missed the last couple merges.
2024-12-01 00:05:27 -06:00
Austin Mroz b0f9796ea3 Refactor decoding format, escape unconvertable
I've had underlying concern for a while now that universally using utf-8
isn't correct because it doesn't respect locale and this serves as
fairly strong confirmation. From a little bit of digging, checking seems
as simple as calling `locale.getencoding()`, but the documentation
claims that this is ANSI for windows (meaning changing it would affect
the majority of users). As a result, I've refactored out all the string
decoding to use a common variable and set it to display an escaped
version of any unconvertable characters, but am leaving the format as
utf-8 until I have further information.

See #324
2024-11-29 10:57:42 -06:00
Austin Mroz fb56b2db7c Remove args indexing from vae decoding
Newer versions of python support indexing multiple dimensions by
prefixing a variable with an aseterisk similar to how it can be used for
functions. This was utilized to discard higher dimensions (excess
frames) produced by vae output when using temporal vae, but causes
syntax errors with older versions of python. This is fixed by using a
while loop instead.

Resolves #321
2024-11-24 19:53:21 -06:00
AustinMrozandGitHub 8a94370cf5 Merge pull request #320 from Kosinkadink/develop
Merge colorspace , scrollbar styling, namespacing fixes
2024-11-24 18:23:30 -06:00
Austin Mroz f5469b1caa Minimal compatibility with temporal vae
Temporal vae conflict with the existing code for batched vae conversion.
Workarounds are being investigated, but these changes should be
sufficient to allow conversion to occur without batching
2024-11-24 18:10:34 -06:00
Austin Mroz 9107873f31 Restrict scrollbar styling to doc pane
See #319
2024-11-24 18:09:03 -06:00
Austin Mroz 8893cdc8be Force colorspace conversion in video formats
The color in output videos being slightly off has been a long standing
issue. While a workaround has been known for several months,
implementing the change would break the loop_count functionality on
Video Combine and gave concerns about compatibility when older versions
of ffmpeg were used.

See
https://www.canva.dev/blog/engineering/a-journey-through-colour-space-with-ffmpeg/
2024-11-19 14:17:33 -06:00
Austin Mroz 4b63e41bce Merge duplicate filters args
If multiple filters (-vf, or -af) are passed to ffmpeg, only one of them
will actually apply. Currently, setting loop_count will break if the
video_format contains any video filter, and the audio pass can not
contain any audio filter since one is always applied for padding.

This does not take filter_complex into consideration. A far more robust
rewrite will be required to do so.
2024-11-19 12:02:10 -06:00
Austin Mroz cc49596040 Swap to namespaced endpoints
VHS adds server endpoints to get a file listing for a directory and to
process video files.. These endpoints were not namespaced to VHS, but
should be.

As external sources (Even the core frontend) referrence the existing
endpoints, they are left in place, but can be deprecated later down the
line.
2024-11-19 11:22:52 -06:00
Austin Mroz 3796aab7d6 Fix Prune Outputs when current dir isn't ComfyUI
The existing prune outputs code naively presumed that the current
directory was the ComfyUI base dir when determining the location of the
output and temp directories. When running ComfyUI through the windows
portable release, this is not the case.

This is trivially solved by using the methods provided in folder paths
to get the output and temp directories.
2024-11-18 14:52:32 -06:00
AustinMrozandGitHub d1d59ba6ac 1.4.0 (#311)
Merge develop branch
2024-10-30 21:41:17 -05:00
Austin Mroz c00ce8e857 Apply select_every_nth to meta batch length calcs
Special thanks to yuvraj for finding this issue.
2024-10-30 21:28:34 -05:00
Austin Mroz 9d608e7a6a Add tests, bump version
Add tests and the code to run them. This code is not run by default and
requires some manual installation.
2024-10-30 16:06:37 -05:00
Austin Mroz 9f3b0d686e Fix documentation videos preloading
a video that autoplays will always preload. Instead the video the
autoplay tag is removed and the video is set to play when the
documentation is opened.

Resolves #310

Also remove repeat from fallback test for descriptions. This should
never display, but has no reason to be repeated
2024-10-30 12:30:14 -05:00
Austin Mroz e2cd11dd1f Fix audio not loading for paths with quotes 2024-10-30 12:13:56 -05:00
Austin Mroz c36cceb511 Add documentation for ffmpeg load nodes
Fix the short description for Load Images Path being the same as Load
Images
2024-10-28 15:50:32 -05:00
Austin Mroz 81a1645e8a Potential workaround for LATENT connections 2024-10-16 16:44:50 -05:00
Austin Mroz d08866b1b8 Meta Batches max size, revert fileInput fix
The max value for frames_per_batch on Meta Batch Manager was far too low
and has been raised significantly.
Resolves #300

The fix for consecutive loads of the same workflow has been merged into
core and is no longer needed here, so it has been reverted.
2024-10-09 14:48:24 -05:00
Austin Mroz fdb71637b7 Instead check that meta_batch is not None 2024-09-24 13:05:24 -05:00
Austin Mroz ad7255ae0b fixes to yieldable passing, file selection
The refactoring broke the passing of the yieldable number of frames
because wrapper for resizing did not account for the additional yield.
In an effort to reduce complexity, some reordering has been performed
and yieldable frames is always passed. Further testing is still needed
for meta batches.

When a workflow is loaded from the file picker the html file input keeps
a reference to that file. If that same file is chosen again, the
changed event doesn't fire. This will cause workflow selection to
silently fail. As a fix, when this file selection is handled, the value
of the file input is cleared. I'm still investigating this, but I plan
to upstream this change after additional testing has been done.

A duplicate style tag has been cleaned up in the js code.

The frames processed by the resize operation when generating with opencv
are once again limited by the frames per batch of an attached meta
batch. The refactoring of order was causing these frames to be
incorrectly discarded
2024-09-18 22:07:08 -05:00
Austin Mroz 742e79331d Refactoring, LoadImagePath node
A couple of miscellaneous fixes after introduction of the load ffmpeg
nodes.

Refactor js code to be a bit more general to the inclusion of multiple
load nodes.

Add an experimental Load Image Path node.
Interest has been raised for adding a dedicated Load (single)
Image node to VHS. From my experience, ffmpeg itself has the most broad
support for conversion between image formats. Using Pillow would provide
limited benefit over the core Load Image node and the implementation
cost here is negligible as the node can use the existing Video Loading
infrastructure. My expectations are that ffmpeg will have better support
over opencv for loading singular images, but I think it best to push it
through as an experimental node. If it proves non-viable, it can always
be removed later. Additional functionality (Mask derivation?) can be
added later.
2024-09-11 14:23:57 -05:00
Austin Mroz de647799b5 Add Load Video FFmpeg experimental nodes
I've been mulling over using ffmpeg for loading videos for close to a
year now. With clean built-in support for experimental nodes, I can
simply implement the functionality as a new experimental node and decide
at a future time if it is worth supporting based off of usefulness

The nodes support masks and offer substantial performance benefits for
rescaling videos or skipping far into a video. As these are new nodes,
and there's no need for backwards compatibility, the start offset is now
expressed as a time in seconds instead of frames. This makes it both
more convenient for a user to choose a starting time (using an external
video player) and makes the actual math to start decoding from that
point in time both simpler and faster.
2024-09-06 23:19:26 -05:00
Austin Mroz 746b0e62bd Check width/height property, suggestion sorting
The width and height returned by openCV sometimes fails for some images
and versions. These values are checked and now manually pulled from the
returned frame data if width or height is invalid

The path completion suggestions are now sorted with priority given to
'.' so that shorter filenames are higher in the list.
2024-08-30 13:01:24 -05:00
Austin Mroz 7cc5061938 Add timestamps to prevent preview caching
commit 23d822 claimed to add this, but seems to only show a version bump
in the changelist, so it has been corrected again.
2024-08-28 16:17:29 -05:00
Austin Mroz 0376e57744 Fix removal of Video Info output
Previously, link redirection was performed on Load Video and Video
Combine to facilitate loading or saving from latents. During this time,
extra inputs and outputs were added that were hidden. The extra output
was removed, but hiding code was not, which resulted in the next output,
video_info, being hidden instead.

Bump version
2024-08-25 11:33:34 -05:00
Austin Mroz 1e54a41f10 Double JSON ecode prompt data
The python JSON module produces output with NaN values that javascript
refuses to consider valid JSON. As a workaround, the prompt data, which
has never been used, is now json encoded before being added as metadata

Resolves #282
2024-08-25 11:33:34 -05:00
Austin Mroz ae51c330ef Rewrite title length calculation
After more thorough inspection, I am further appalled by the title
length calculation. There is a measureText function when working with
canvases which the correct way to get the length of a piece of text, but
the title text which has been causing issues seems to be the singular
place in the litegraph code where it is not used.

The code to recalculate title length now uses measureText, with
additional padding to account for the node collapse button, and the
documentation button is hidden when a ndoe is collapsed (during which
time, changes to a nodes minimum width can not be made)
2024-08-25 11:33:34 -05:00
AustinMrozandGitHub 9700a6c0bf Merge pull request #277 from Kosinkadink/develop
Merge documentation and misc fixes
2024-08-23 12:33:22 -05:00
Austin Mroz f3eadf9b99 Unbatch cleanup
Unbatch has been renamed from Unbatch Any. Including Any in the title
adds a level of hubris I wish to avoid.

The output type is reset to connect with nothing when a link is
disconnected
2024-08-23 12:26:05 -05:00
Austin Mroz 0cdc93273e version bump, grammar, set experimental flag 2024-08-22 17:29:41 -05:00
Austin Mroz 847a24f69c Sizing adjustments to documentation 2024-08-22 15:47:54 -05:00
Austin Mroz 54a38cb963 Fix input not swapping to VHS_PATH
field renaming was originally done to act as a guard and make sure a
widget was never converted twice, but this is already handled by the
widget no longer being of type text and this was causing breakage as
nodes created after the first would not have inputs changed to VHS_PATH
2024-08-22 14:00:43 -05:00
Austin Mroz 14443ebdfc Support latents in Unbatch Any 2024-08-21 15:43:40 -05:00
Austin Mroz 60b9bedf27 Add Unbatch Any node.
It's come up a couple of times now that Video Combine is not batch aware
and will produce an output video for each batch of input images. This
causes confusion and there wasn't previously a simple fix to convert a
series batched set of images into a single unbatched tensor.

This adds a single experimental node to unbatch any arbitrary input.
Tested for tensors, lists (this would be lists of lists) and strings.
The node is thin, but memory overhead hasn't been tested

Includes javascript to set set the output type equal to the input type
even though any input may be added.
2024-08-20 17:53:46 -05:00
Austin Mroz 76ddf5b676 Documentation revision, reset scroll, select fix
Several descriptions have been updated after proof reading

When the help window changed nodes, the scroll position would be kept
between even if the targeted node has swapped. Scroll is now always
reset to the top when documentation is pulled up for a node.

The select code improperly accounted for inputs or outputs not existing.
This has been fixed
2024-08-20 00:55:45 -05:00
Austin Mroz adcf31fac8 Don't force updated converted converted inputs
As a workaround to loading of old workflows where types have changed,
connectable inputs will reset their types to what is given in the node
definition. This was implemented to solve old workflows prior to the
removal of VHS_AUDIO still having VHS_AUDIO as the type on the inputs.

However, this creates problems when loadinga workflow where an images
input has been dynamically converted to latents.

A more comprehensive solution will likely be required in the future, but
for now, this keeps things funcitonal
2024-08-19 15:49:51 -05:00
Austin Mroz b471f134ed Close help on workflow load, font size, docs
Previously, documentation would remain open to ghost nodes on graph
change since the implementation kept the linked node alive by
maintaining a reference to it.

This is fixed by having the active help node cleared on graph change.

Removed the reduced font size from the short description. This probably
needs further adjustments, but it solves an annoyance where short_descs
are a smaller font size than subsequent lines of opening descriptions

Fixed poor wording in the description for Prune Outputs
2024-08-19 12:16:04 -05:00
Austin Mroz 6e8a53b3e6 Add MOV extension to Load Video, ignore case
Load Video (Upload) and Load Video (Path) will now display files with
the MOV extension and will ignore case when checking if a file is in the
list of allowed extensions.
2024-08-19 11:50:14 -05:00
Austin Mroz a4135a719a Support variable channels in vae
Some VAEs, like the one for flux, encode to a number of channels other
than 4. The code for streaming vae encodes previously used a hard coded
value of 4, but has been updated to pull the expected number of channels
from the vae itself.
2024-08-17 21:11:04 -05:00
Austin Mroz ad6b13a3a3 Further documentation and autosizing
Autosize VHS nodes to ensure title and documentation button fit in
bounds. This isn't as configurable as the autosize implementation given
to ADE and ACN, but it's more lightweight and good enough.

Remove a number of duplicate spaces from documentation

Add framework code to generate variant documentation for nodes with
image/latent/mask variants

Every node now has at least some documentation.
2024-08-17 17:42:25 -05:00
Austin Mroz 8cc4138a48 Group first documentation line, log missing, comma
In documentation, the first element is now attatched directly instead of
being included in a div. If this element is an image or video that has
its own tag, it's still hidden, but this ensures that text descriptions
always display even if they are part of a large group.

Nodes missing documentation are now logged as a developmental warning.
Nodes to be implemented are currently assigned None so this is mostly to
ensure no nodes are fully unaccounted for.

Made a pass of appending commas to the end of every documentation line.
Several times this caused problems of strings concatenating and is
cleaner from aformatting standpoint and for future diffs
2024-08-16 19:31:23 -05:00
Austin Mroz 0a26e2defd Fix I/O as latent
At some point recently, likely the execution inversion update, the
conditional toggles to load or save as latent stopped working. While I
don't have a full grasp of the entire system yet, included in the new
documentation is a recommended way of creating a node which produce or
consume multiple types of input and output. The code has been rewritten
to follow this method.

As a result:
- Things function again
- Shady link swapping on queue is no longer performed
- The image input on Video Combine is once again required
2024-08-16 14:46:45 -05:00
Austin Mroz c0b9fc90ab Placeholder support for VHS_PATH
The placeholder option can now be used to specify a default path value
without actually setting one. This gives parity with the equivalent base
ComfyUI option and maintains the example format without requiring it be
erased when a new instance of the node is added.
2024-08-15 16:31:19 -05:00
Austin Mroz 21344549fe Documentation and cosmetic fixes
More documentation has been written

Removed an errant line causing all short descriptions to be the same

Renabled scrolling and manually unscroll the base window

Manually calculate widget starting height for nodes that do not contain
a domWidget and therefore, do not have a y attribute

Use monospace font for + and - in collapse buttons.
There were alternative solutions to the spacing problem that would keep
other text from rearranging, but this looks cleaner as the surrounding
brackets don't move
2024-08-14 14:45:00 -05:00
Austin Mroz d2cf5d7b03 Further text documentation, scrolling removal
When the documentation window is partially off-screen and a node element
is hovered, the entire canvas would scroll horizontally to try and fit
the documentation window on-screen. The documentation scrolling has been
temporarily disabled as alternatives are being investigated.

Fixed a missing index when calculating the widget under the mouse cursor
for widgets that override computeSize
2024-08-13 21:01:00 -05:00
Austin Mroz ccf95b92ea Add a node to convert VHS_FILENAMES to string
Resolves #202
Resolves #271
Closes #272
2024-08-13 21:00:57 -05:00
Austin Mroz 289d7d8d99 Work in progress documentation writing.
Many lines of text, but no images yet.

Add helper function to embed videos in documentation
2024-08-13 21:00:53 -05:00
Jedrzej KosinskiandGitHub 6bffe8b90f Merge PR #273 from Kosinkadink/develop - documentation js collapse improvements
AustinMroz' improvements to description js
2024-08-11 23:49:25 -05:00
Jedrzej Kosinski 5c7455a1c8 version bump 2024-08-11 23:47:35 -05:00
Jedrzej Kosinski 5333656fe7 Merge branch 'develop' of https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite into develop 2024-08-11 22:42:40 -05:00
Austin Mroz ca04cd3ac3 Fix display truncation issues, simplify logic
Collapsed entries are now properly truncated

Collapsing is done on js side to prevent the need for synchronization,
which also greatly simplifies the complexity of the html generation code
and solves the difficulty of hiding sub-elements (a picture) when the
parent starts collapsed

If needed, the selected element is scrolled to

Known issue: When scrolled to the bottom and collapsing an entry, the
help scrolls up the clicked button is no longer underneath the mouse as
you can't scroll past the end. Solving this is likely unfeasible
2024-08-11 15:14:06 -05:00
Austin Mroz d917f01297 Fix reflow, content not being properly cutoff
Known issue: Lines are shortened more then they should be
2024-08-11 12:29:39 -05:00
Austin Mroz 35f372c808 Collapse individual, force image hiding, height
If a dict key ends with `_collapsed` that ending will be removed, but
the dict item will start collapsed

Image and Video elements are forcibly hidden when a parent node is
collapsed.

Experimentally, single line height has been changed to cap height height
instead of using em
2024-08-11 10:39:12 -05:00
Jedrzej Kosinski 7dc2a03f9b Merge branch 'develop' of https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite into develop 2024-08-09 19:10:40 -05:00
Austin Mroz 9dff4d6500 Add method to have section group start collapsed
Remove duplicate line
2024-08-09 18:55:09 -05:00
Austin Mroz bf2a9402d0 Workaround for scaling issues
The new side and menu bar provides an additional offset which must be
accounted for in the position calculations for the helpDOM

There is likely a cleaner way to obtain either the offset itself, or a
context transform with the offset included, but the only means I've
found so far from my digging is to pull the bounding client rect from
the canvas element itself
2024-08-08 14:10:40 -05:00
Austin Mroz 41ca4439e1 Fix mistaken ffmpeg path.
As a workaround to displaying transparent videos a to support correct
audio synchronization in advanced previews, a initial prepass is
performed to query information on the video file. This prepass
mistakenly used a hardcoded `ffmpeg` instead of the proper ffmpeg_path
2024-08-08 02:00:40 -05:00
Austin Mroz 8140415680 Fix mistaken ffmpeg path.
As a workaround to displaying transparent videos a to support correct
audio synchronization in advanced previews, a initial prepass is
performed to query information on the video file. This prepass
mistakenly used a hardcoded `ffmpeg` instead of the proper ffmpeg_path
2024-08-08 01:42:11 -05:00
AustinMrozandGitHub a6ba37ee28 Merge pull request #266 from Kosinkadink/develop
Remove is_integer check
2024-08-07 20:03:31 -05:00
Austin Mroz 1ae5379473 Remove is_integer check
is_integer was only added in python 3.12, so the line caused issues for
any newer version of python. Upon further reveiw, the call serves no
purpose as total frames is already cast as int and, if any error state
could exist as a non integer value (inf or nan) it would have already
thrown an error. Instead a check is simply performed to verify that
total_frames is greater than 0

Resolves #265
2024-08-07 20:00:01 -05:00
Jedrzej KosinskiandGitHub 156164c331 Merge PR #264 from Kosinkadink/node_documentation
Node documentation
2024-08-07 17:00:20 -05:00
Jedrzej Kosinski 4664081a2a version bump 2024-08-07 16:46:07 -05:00
Jedrzej Kosinski 8b9df52180 Merge branch 'main' into node_documentation 2024-08-07 16:45:26 -05:00
Austin Mroz e4d7c30ac3 Merge minor outstanding changes 2024-08-07 14:58:30 -05:00
Austin Mroz 013473335a Fix clobbering of OnDrawForeground
Bizarrely, setting the prototype for onDrawForeground was resulting in
the onDrawForeground function getting clobbered if another extension
(such as Jovimetrix) adds a function to onDrawForeground, even if
the standard wrapping procedures was done. This patch fixes both this
incompatibility, and the callback being applied multiple times for each
node created
2024-08-07 14:49:46 -05:00
Austin Mroz 5aa08cb61b Remove when hovering in the documentation window
Previously, the title attribute was used to link a selected widget to
the correct spot in the documentation, but this had the side effect of
causing a tooltip to appear when the documentation itself was moused
over. Since this tooltip would block the documentation text itself and
provide no useful information to the user, this lookup information is
now stored as vhs_title attribute
2024-08-07 13:32:14 -05:00
Austin Mroz 0e0bcc72f8 Add simple description formatting if plaintext.
If the description doesn't have any children elements when interpreted
as html, it is assumed to be plaintext and has some basic formatting
applied. New lines are replaced with a <br> tag so they function as
expected and the first line is used as a short description for the node,
which shows when the title is moused over, or when selecting the node in
the search box.

If desired, this could be expanded to apply simple markdown formatting
such as lists or titles.
2024-08-07 13:23:51 -05:00
Austin Mroz e667990015 Add toggles for muting video previews
To better facilitate working with audio, videos previews were changed to
unmute when moused over, but it has been requested multiple times that
an option be added to have the previews remain muted. This is now added
in two forms: A UI setting like Advanced Previews to have new previews
default to muted on creation, and a Right Click option to toggle the
mute state on individual previews.
2024-08-06 21:46:15 -05:00
Austin Mroz 8e774e44ce Add image function for documenation, fix path api
A helper function has been added to assist in adding images to
documentation

A preceding forward slash is required when using the new ComfyUI
frontend. Since this was missing from the call to fetch files for path
widgets, they weer not functioning as expected.
2024-08-06 21:30:24 -05:00
Jedrzej Kosinski 9e841a5b88 version bump 2024-08-06 17:35:47 -05:00
Jedrzej KosinskiandGitHub bd077e42b6 Merge pull request #262 from ComfyNodePRs/licence-update
Update PyProject Toml - License
2024-08-06 17:21:38 -05:00
Austin Mroz 12b9f8d00f Formatting, minor fixes
Add check for null when pulling short description

Split the test description into multiple lines to clean up diffs

Stop decreasing size after an arbitrary depth

Include example of documenting widget values
2024-08-05 15:19:23 -05:00
Austin Mroz 8a564019ef After load, use human readable description.
In the new frontend, the DESCRIPTION field is being used for
standardized basic documentation. To keep the more advanced features
of this branch, a short human readable  description can now be
derived from the full description. After initial load, the description
field is replaced from this shorter description.
2024-08-05 14:50:21 -05:00
Austin Mroz 3dc15c4507 Refactor for code sharing
Wrap computeSize so that the question mark doesn't overlap with the
node title
2024-08-04 04:01:20 -05:00
Austin Mroz 3489bccd20 Fix text reflow, collapse size, scrollbar
Rather than adjusting font size, a transformation is applied to the div
itself, preventing reflow on zoom

The expand/collapse button is now only clickable on the text itself

Basic styling has been applied to the scrollbar when needed
2024-08-04 02:23:00 -05:00
Austin Mroz bb7186aae4 Fixes to Meta Batch counts
The logic for determining how many subexecutions would be performed by a
Meta Batch was insufficient for all but the most narrow of cases and has
been reworked

As part of these changes, a progress bar is now displayed for Load Video
even when frame_load_cap is unset
2024-08-03 14:18:20 -05:00
snomiao b253112553 chore(licence-update): Update PyProject Toml - License 2024-08-02 23:03:35 +00:00
Austin Mroz 8f8df32db1 Add additional format options for transparency
Updated ProRes to allow transparent output when set to Profile 4. As
part of this change, pix_fmt is no longer explicitly set for ProRes, but
the expected format is automatically applied from for each profile.

Added rgba as an output pix_fmt for the nvenc formats. These have not
been tested and may not function.

Fix incorrect escape in Advanced Preview code.
2024-07-31 02:19:27 -05:00
Austin Mroz fc328d88ce Additional fix for loading API workflows 2024-07-30 14:36:52 -05:00
Austin Mroz 7c313fd9fb Merge ytdl_support, audio QOL 2024-07-30 02:36:13 -05:00
Austin Mroz 9405e803c2 Forcibly restore input type, test audio exists
When loading a workflow from before when VHS_AUDIO was swapped for
AUDIO, the input type of VideoCombine nodes would be set to VHS_AUDIO
despite this being invalid. This has now been fixed.
See #254

With the change to AUDIO, an error is now raised when audio doesn't
exist but an audio connection is used. This is necessary to ensure
correct error handling with nodes outside VHS. However, this
inconveniences those who always connect the audio input directly to
video combine. To resolve this, Video Combine now catches the thrown
error and skips embedding audio if needed.
Resolves #258
2024-07-30 02:17:27 -05:00
Austin Mroz 990e6e2448 Fix loading of API workflows 2024-07-30 01:40:39 -05:00
Austin Mroz 3a906cfe8f Close help if the node is removed 2024-07-26 19:31:50 -05:00
Austin Mroz a0f616cbef Implement collapsing
Adjust offset when selecting inputs
2024-07-26 16:12:18 -05:00
Austin Mroz 1e9f7e7257 POC node documentation implementation.
Nodes with a DESCRIPTION variable have that variable displayed as raw
html. A documentation file has been added to assist in
mocking documentation in a standardized format.

Logic to allow connecting node interaction to equivalent documentation
is partial. Currently, the hovered item is highlighted, but the intent
is to either automatically scroll to the selected region, or collapse
other regions.
2024-07-26 01:30:34 -05:00
Austin Mroz d6ac0b999b After upload, use name provided by upload endpoint
By default, If an uploaded file has the same name as an already existing
file, a number is appended to the end and the new name is returned by
the endpoint. When this occurs, modified filename is now properly added
to the filelist and swapped to.
2024-07-18 03:12:55 -05:00
Austin Mroz e1c4e939ec Workaround for DeepFuze overriding onExecuted
DeepFuze overrides onExecuted on every node, doesn't chain existing
callbacks, and raises an exception if the node doesn't have the expected
audio variables.

In order to prevent loading order shenanigans. This has been by fixed by
wrapping the function to not apply on VHS namespaced nodes.

See #242
2024-07-15 14:58:45 -05:00
Jedrzej KosinskiandGitHub 70faa9bcef Merge PR #251 from Kosinkadink/batch-operations
Select Nodes for Latent/Images/Masks
2024-07-11 22:08:51 -05:00
Jedrzej Kosinski 7b934a81c7 version bump 2024-07-11 22:00:48 -05:00
Jedrzej Kosinski 623a603ed3 Modified almost all other latent nodes besides Merge Latents to handle other tensor properties on latents dict besides just "samples"; Merge Latents will require some work to do properly for other props that may not exist on all latents that are passed in 2024-07-11 21:59:49 -05:00
Jedrzej Kosinski 3b7492d023 Added Select Latents/Images/Masks nodes, renamed some nodes to remove Batch from names, renamed Duplicate nodes to Repeat 2024-07-11 21:34:53 -05:00
Austin Mroz d6cf89c501 Clean up get_audio try/catch
The prior implementation made a best effort to continue in the event of
an error, but immediately threw an error afterwards since there was no
output from the subprocess.

As get_audio isn't called until the audio connection from a Load Video
is used, it's safe to raise an exception here and properly expose the
error message from ffmpeg as is done elsewhere.

See #250
2024-07-11 11:03:32 -05:00
Austin Mroz bd5381ec8f Add safety check to VHS_AUDIO link check
Add safety check that the connected node has a nodeDef.
See #249
2024-07-10 13:04:40 -05:00
Austin Mroz b5d907bade WIP preview synchronization 2024-07-10 12:57:06 -05:00
Austin Mroz 8c50452255 Skip download attempt when yt-dl unavailable 2024-07-09 17:07:00 -05:00
Austin Mroz 48edad3aea Fix missing is_url imports 2024-07-09 13:30:39 -05:00
Austin Mroz 4838634372 Add support for downloading urls with yd-dl
Executable preference is
VHS_YTDL environment variable > yt-dlp > youtube-dl

Downloaded videos are cached in temp so they are not preserved between
reboots

Both Load Video (Path) and Load Audio (Path) are supported
2024-07-09 13:10:56 -05:00
Austin Mroz ce5d0a7548 Fix dragging and scrolling on previews 2024-07-09 11:35:03 -05:00
Austin Mroz 29fae7510f Fix Audio to VHS_Audio, add VHS_Audio to Audio 2024-07-06 11:27:00 -05:00
Austin Mroz 962e8e86ef Migrate from VHS_AUDIO to built-in AUDIO
Merge branch 'audio_format'
2024-07-05 18:38:53 -05:00
Austin Mroz c7b1dc7328 Log current meta batch step to terminal
I was mistaken. This is actually quite easy and provides decent qol
2024-07-02 01:15:55 -05:00
Austin Mroz 0037f49aee Add check for null in cg-use-everywhere workaround
Resolves #241
2024-06-30 23:02:54 -05:00
Austin Mroz 3258f3cac5 Add temporary workaround for cg-use-everywhereo
cg-use-everywhere automatically creates links when when a graph is
executed. This is generally nice utility, but conflicts with the
paradigm of the new VAE inputs and is particularly problematic for Load
Video (where it can not be solved on the python side).

As a workaround, a wrapper is added around the graphToPrompt call that
cg-use-everywhere wraps which deletes any links from LoadVideo nodes
that do not actually exist.
2024-06-30 01:10:25 -05:00
Austin Mroz 0be3bc0597 Additional fixes for unreadable memory_limit
The safety code to ignore the memory limit when it could not be
calculated was incorrectly applied after adding the vae changes.

They have been reapplied. See #240
2024-06-30 01:10:08 -05:00
Austin Mroz 23d822c423 Add timestamps to image requests to prevent caching
Bump version
2024-06-29 03:02:13 -05:00
40 changed files with 6534 additions and 904 deletions
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="Hanzo-VideoHelper">
<rect width="1280" height="640" fill="#0A0A0A"/>
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">Hanzo-VideoHelper</text>
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">Nodes related to video workflows</text>
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+6 -2
View File
@@ -7,14 +7,18 @@ on:
paths:
- "pyproject.toml"
permissions:
issues: write
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'Kosinkadink' }}
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
uses: Comfy-Org/publish-node-action@v1
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here.
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here.
Symlink
+1
View File
@@ -0,0 +1 @@
LLM.md
+19
View File
@@ -0,0 +1,19 @@
# Hanzo-VideoHelper
**Org:** hanzoai · **Ecosystem:** hanzo · **Path:** `/Users/a/work/hanzo/hanzoai/Hanzo-VideoHelper`
**Origin:** https://github.com/hanzoai/Hanzo-VideoHelper.git
## Discovery
This file (`CLAUDE.md`) is the canonical agent-facing readme; `LLM.md` is a symlink to it. Update either name and both stay in sync.
## Where to look first
- `README.md` — human-facing overview (if present)
- `package.json` / `Cargo.toml` / `pyproject.toml` / `go.mod` — language & deps
- `.github/workflows/` — CI surface
- `docs/` — extended docs (if present)
## Sibling repos
See the org-level `LLM.md` at `/Users/a/work/hanzo/hanzoai/LLM.md` for the full inventory of sibling repos and inter-repo dependencies.
+68 -99
View File
@@ -1,110 +1,79 @@
# ComfyUI-VideoHelperSuite
Nodes related to video workflows
<p align="center"><img src=".github/hero.svg" alt="Hanzo-VideoHelper" width="880"></p>
## I/O Nodes
### Load Video
Converts a video file into a series of images
- video: The video file to be loaded
- force_rate: Discards or duplicates frames as needed to hit a target frame rate. Disabled by setting to 0. This can be used to quickly match a suggested frame rate like the 8 fps of AnimateDiff.
- force_size: Allows for quick resizing to a number of suggested sizes. Several options allow you to set only width or height and determine the other from aspect ratio.
- frame_load_cap: The maximum number of frames which will be returned. This could also be thought of as the maximum batch size.
- skip_first_frames: How many frames to skip from the start of the video after adjusting for a forced frame rate. By incrementing this number by the frame_load_cap, you can easily process a longer input video in parts.
- select_every_nth: Allows for skipping a number of frames without considering the base frame rate or risking frame duplication. Often useful when working with animated gifs
A path variant of the Load Video node exists that allows loading videos from external paths
![step](https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite/assets/4284322/b5fc993c-5c9b-4608-afa4-48ae2e1380ef)
![resize](https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite/assets/4284322/98d2e78e-1c44-443c-a8fe-0dab0b5947f3)
If [Advanced Previews](#advanced-previews) is enabled in the options menu of the web ui, the preview will reflect the current settings on the node.
### Load Image Sequence
Loads all image files from a subfolder. Options are similar to Load Video.
- image_load_cap: The maximum number of images which will be returned. This could also be thought of as the maximum batch size.
- skip_first_images: How many images to skip. By incrementing this number by image_load_cap, you can easily divide a long sequence of images into multiple batches.
- select_every_nth: Allows for skipping a number of images between every returned frame.
# Hanzo-VideoHelper
A path variant of Load Image sequence also exists.
### Video Combine
Combines a series of images into an output video
If the optional audio input is provided, it will also be combined into the output video
- frame_rate: How many of the input frames are displayed per second. A higher frame rate means that the output video plays faster and has less duration. This should usually be kept to 8 for AnimateDiff, or matched to the force_rate of a Load Video node.
- loop_count: How many additional times the video should repeat
- filename_prefix: The base file name used for output.
- You can save output to a subfolder: `subfolder/video`
- Like the builtin Save Image node, you can add timestamps. `%date:yyyy-MM-ddThh:mm:ss%` might become 2023-10-31T6:45:25
- format: The file format to use. Advanced information on configuring or adding additional video formats can be found in the [Video Formats](#video-formats) section.
- pingpong: Causes the input to be played back in the reverse to create a clean loop.
- save_output: Whether the image should be put into the output directory or the temp directory.
Returns: a `VHS_FILENAMES` which consists of a boolean indicating if save_output is enabled and a list of the full filepaths of all generated outputs in the order created. Accordingly `output[1][-1]` will be the most complete output.
Depending on the format chosen, additional options may become available, including
- crf: Describes the quality of the output video. A lower number gives a higher quality video and a larger file size, while a higher number gives a lower quality video with a smaller size. Scaling varies by codec, but visually lossless output generally occurs around 20.
- save_metadata: Includes a copy of the workflow in the ouput video which can be loaded by dragging and dropping the video, just like with images.
- pix_fmt: Changes how the pixel data is stored. `yuv420p10le` has higher color quality, but won't work on all devices
### Load Audio
Provides a way to load standalone audio files.
- seek_seconds: An optional start time for the audio file in seconds.
**Video I/O suite for the Hanzo ecosystem**
## Latent/Image Nodes
A number of utility nodes exist for managing latents. For each, there is an equivalent node which works on images.
### Split Batch
Divides the latents into two sets. The first `split_index` latents go to ouput A and the remainder to output B. If less then `split_index` latents are provided as input, all are passed to output A and output B is empty.
### Merge Batch
Combines two groups of latents into a single output. The order of the output is the latents in A followed by the latents in B.
If the input groups are not the same size, the node provides options for rescaling the latents before merging.
### Select Every Nth
The first of every `select_every_nth` input is passed and the remainder are discarded
### Get Count
### Duplicate Batch
Part of [Hanzo Painter](https://github.com/hanzoai/painter) - AI-powered watermark removal and video inpainting platform.
## Video Previews
Load Video (Upload), Load Video (Path), Load Images (Upload), Load Images (Path) and Video Combine provide animated previews.
Nodes with previews provide additional functionality when right clicked
- Open preview
- Save preview
- Pause preview: Can improve performance with very large videos
- Hide preview: Can improve performance, save space
- Sync preview: Restarts all previews for side-by-side comparisons
[![Upstream](https://img.shields.io/badge/upstream-Kosinkadink%2FComfyUI-VideoHelperSuite-blue)](https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite)
[![Hanzo AI](https://img.shields.io/badge/Hanzo-AI-orange)](https://hanzo.ai)
### Advanced Previews
Advanced Previews must be manually enabled by clicking the settings gear next to Queue Prompt and checking the box for VHS Advanced Previews.
If enabled, videos which are displayed in the ui will be converted with ffmpeg on request. This has several benefits
- Previews for Load Video nodes will reflect the settings on the node such as skip_first_frames and frame_load_cap
- This makes it easy to select an exact portion of an input video and sync it with outputs
- It can use substantially less bandwidth if running the server remotely
- It can greatly improve the browser performance by downsizing videos to the in ui resolution, particularly useful with animated gifs
- It allows for previews of videos that would not normally be playable in browser.
- Can be limited to subdirectories of ComyUI if `VHS_STRICT_PATHS` is set as an environment variable.
## About
This fucntionality is disabled since it comes with several downsides
- There is a delay before videos show in the browser. This delay can become quite large if the input video is long
- The preview videos are lower quality (The original can always be viewed with Right Click -> Open preview)
Hanzo-VideoHelper is a Hanzo-maintained fork of Kosinkadink/ComfyUI-VideoHelperSuite, providing Video I/O suite for video processing. This fork is part of the Hanzo ComfyUI ecosystem, ensuring tested compatibility and seamless integration with other Hanzo custom nodes.
## Video Formats
Those familiar with ffmpeg are able to add json files to the video_formats folders to add new output types to Video Combine.
Consider the following example for av1-webm
```json
{
"main_pass":
[
"-n", "-c:v", "libsvtav1",
"-pix_fmt", "yuv420p10le",
"-crf", ["crf","INT", {"default": 23, "min": 0, "max": 100, "step": 1}]
],
"audio_pass": ["-c:a", "libopus"],
"extension": "webm",
"environment": {"SVT_LOG": "1"}
}
## Installation
### As Part of Hanzo Painter (Recommended)
```bash
git clone git@github.com:hanzoai/painter.git
cd painter
make setup # Automatically installs all Hanzo nodes
```
Most configuration takes place in `main_pass`, which is a list of arguments that are passed to ffmpeg.
- `"-n"` designates that the command should fail if a file of the same name already exists. This should never happen, but if some bug were to occur, it would ensure other files aren't overwritten.
- `"-c:v", "libsvtav1"` designates that the video should be encoded with an av1 codec using the new SVT-AV1 encoder. SVT-AV1 is much faster than libaom-av1, but may not exist in older versions of ffmpeg. Alternatively, av1_nvenc could be used for gpu encoding with newer nvidia cards.
- `"-pix_fmt", "yuv420p10le"` designates the standard pixel format with 10-bit color. It's important that some pixel format be specified to ensure a nonconfigurable input pix_fmt isn't used.
`audio pass` contains a list of arguments which are passed to ffmpeg when audio is passed into Video Combine
### Standalone Installation
`extension` designates both the file extension and the container format that is used. If some of the above options are omitted from `main_pass` it can affect what default options are chosen.
`environment` can optionally be provided to set environment variables during execution. For av1 it's used to reduce the verbosity of logging so that only major errors are displayed.
`input_color_depth` effects the format in which pixels are passed to the ffmpeg subprocess. Current valid options are `8bit` and `16bit`. The later will produce higher quality output, but is experimental.
```bash
cd ComfyUI/custom_nodes
git clone git@github.com:hanzoai/Hanzo-VideoHelper.git
cd Hanzo-VideoHelper
pip install -r requirements.txt # If requirements.txt exists
```
Fields can be exposed in the webui as a widget using a format similar to what is used in the creation of custom nodes. In the above example, the argument for `-crf` will be exposed as a format widget in the webui. Format widgets are a list of up to 3 terms
- The name of the widget that will be displayed in the web ui
- Either a primitive such as "INT" or "BOOLEAN", or a list of string options
- A dictionary of options
## Hanzo ComfyUI Ecosystem
Hanzo-VideoHelper is part of a curated stack of ComfyUI nodes maintained by Hanzo AI. All nodes work together seamlessly and are tested as a unified stack.
| Node | Purpose |
|------|---------|
| [Hanzo-DiffuEraser](https://github.com/hanzoai/Hanzo-DiffuEraser) | Content-aware inpainting |
| [Hanzo-VideoHelper](https://github.com/hanzoai/Hanzo-VideoHelper) | Video I/O suite |
| [Hanzo-EasyUse](https://github.com/hanzoai/Hanzo-EasyUse) | Workflow utilities |
| [Hanzo-KJNodes](https://github.com/hanzoai/Hanzo-KJNodes) | Core utilities |
| [Hanzo-LayerStyle](https://github.com/hanzoai/Hanzo-LayerStyle) | Layer compositing |
| [Hanzo-MLX](https://github.com/hanzoai/Hanzo-MLX) | Apple Silicon acceleration |
## Upstream
This is a Hanzo-maintained fork of [Kosinkadink/ComfyUI-VideoHelperSuite](https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite).
- **Upstream Sync**: Regularly updated from original repository
- **Hanzo Enhancements**: Custom improvements for Hanzo ecosystem
- **Tested Compatibility**: Verified to work with other Hanzo nodes
## Contributing
### To Hanzo Fork
For Hanzo-specific features:
1. Fork this repository
2. Create a feature branch
3. Submit a PR to `hanzoai/Hanzo-VideoHelper`
### To Upstream
For general improvements:
1. Submit PRs to [Kosinkadink/ComfyUI-VideoHelperSuite](https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite)
2. Hanzo fork will sync these improvements
## About Hanzo AI
Part of the [Hanzo AI](https://hanzo.ai) ecosystem:
- **hanzo.ai** - Core AI infrastructure platform
- **hanzo.io** - Business solutions
- **hanzo.network** - Decentralized compute marketplace
---
Made with ❤️ by [Hanzo AI](https://hanzo.ai)
+3
View File
@@ -1,6 +1,9 @@
from .videohelpersuite.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
import folder_paths
from .videohelpersuite.server import server
from .videohelpersuite import documentation
from .videohelpersuite import latent_preview
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
documentation.format_descriptions(NODE_CLASS_MAPPINGS)
+2 -2
View File
@@ -1,8 +1,8 @@
[project]
name = "comfyui-videohelpersuite"
description = "Nodes related to video workflows"
version = "1.2.0"
license = "LICENSE"
version = "1.7.9"
license = { file = "LICENSE" }
dependencies = ["opencv-python", "imageio-ffmpeg"]
[project.urls]
+5
View File
@@ -0,0 +1,5 @@
Code to automate execution of the tests and evaluate the results.
Distributed as a `custom node`, and can be installed by copying or simlinking to the `custom_nodes` directory.
Requires that ffprobe be available and added to the path. Note that imageio-ffmpeg does not bundle ffprobe.
When installed, it adds a new sidebar tab to automate running one, or a folder of tests. This requires that the `Use new menu and workflow management` setting not be disabled
+6
View File
@@ -0,0 +1,6 @@
from . import server
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+60
View File
@@ -0,0 +1,60 @@
import subprocess
import json
import os
import torch
import shutil
import server
import folder_paths
web = server.web
@server.PromptServer.instance.routes.post("/VHS_test")
async def test(request):
try:
req_data = await request.json()
output = req_data['output']['gifs'][0]
filename = output['filename']
typ = output['type']
base_args = ["ffprobe", "-v", "error", '-count_packets', "-show_entries", "stream", "-of", "json"]
video = folder_paths.get_annotated_filepath(f'{filename} [{typ}]')
vprobe = json.loads(subprocess.run(base_args + ['-select_streams', 'v:0', video],
capture_output=True, check=True).stdout)['streams'][0]
aprobe = json.loads(subprocess.run(base_args + ['-select_streams', 'a:0', video],
capture_output=True, check=True).stdout)['streams']
probe = {'video': vprobe}
if len(aprobe) > 0:
probe['audio'] = aprobe[0]
errors = []
compare = None
for test in req_data['tests']:
if test['type'] == 'compare':
compare = test
continue
key = test['key']
expected = test['value']
actual = probe[test['type']][key]
if expected != actual:
#Consider always dumping type?
errors.append(f'{key}: {expected} != {actual}')
if len(errors) == 0 and compare is not None:
if not os.path.exists(compare['filename']):
os.makedirs(os.path.split(compare['filename'])[0], exist_ok=True)
shutil.copy(video, compare['filename'])
print("Missing comparison file has been initialized from output:", os.path.abspath(compare['filename']))
else:
#NOTE: This does not include the full memory optimizations of VHS
#Tests should be small
#TODO: Figure out way to do opacity comparison. May need to do blending in python
#(easy, but slower and more memory intensive)
diff = subprocess.run(['ffmpeg', '-v', 'error', '-i', video, '-i', compare['filename'], '-filter_complex', 'blend=all_mode=grainextract', '-pix_fmt', 'rgb24', '-f', 'rawvideo', '-'], stdout=subprocess.PIPE, check=True).stdout
diff = torch.frombuffer(diff, dtype=torch.uint8).to(dtype=torch.float32).div_(255)
#diff = diff.reshape((-1,4))
d = (diff-0.5).abs().sum()/diff.size(0)
if d > compare['tolerance']:
errors.append(f'Similarity is outside specified tolerance: {d}')
else:
print('d:', d)
return web.json_response(errors)
except Exception as e:
return web.json_response(str(e))
+96
View File
@@ -0,0 +1,96 @@
import {app} from "../../../scripts/app.js";
import {api} from "../../../scripts/api.js";
let watched_nodes = {}
let resolve = undefined
let testURL = api.apiURL("/VHS_test")
let errors = []
api.addEventListener("executed", async function ({detail}) {
if (watched_nodes && watched_nodes[detail?.node]) {
if (detail?.output?.unfinished_batch) {
return
}
let requestBody = {tests: watched_nodes[detail.node], output: detail.output}
try {
let req = await fetch(api.apiURL("/VHS_test"),
{method: "POST", body: JSON.stringify(requestBody)});
let testResult = await req.json()
if (testResult.length != 0) {
errors.push(testResult)
}
} catch(e) {
errors.push(e)
}
if (!(watched_nodes.length -= 1)) {
resolve()
}
}
});
const workflowService = app.extensionManager.workflow
async function runTest(file) {
if (!file?.name?.endsWith(".json")) {
return false
}
let workflow = JSON.parse(await file.text())
await app.loadGraphData(workflow)
//NOTE: API is not used so workflow data is actually processed
watched_nodes = workflow.tests
errors = []
let p = new Promise((r) => resolve = r)
await app.queuePrompt()
//block until execution completes
await p
watched_nodes = {}
if (errors.length > 0) {
app.ui.dialog.show("Failed " + errors.length + " tests:\n" + errors)
return true
}
await workflowService.closeWorkflow(workflowService.activeWorkflow, {warnIfUnsaved: false})
return false
}
let iconOverride = document.createElement("style")
iconOverride.innerHTML = `.VHSTestIcon:before {content: '🧪';}`
document.body.append(iconOverride)
let testSidebar = {id: 'VHStest', title: 'VHS Test', icon: 'VHSTestIcon', type: 'custom',
render: (e) => {
e.innerHTML = `Select a folder containing tests
<input>
Or select a single test
<input>
`
const folderInput = e.children[0]
const fileInput = e.children[1]
Object.assign(folderInput, {
type: "file",
webkitdirectory: true,
onchange: async function() {
const startTime = Date.now()
let failedTests = false
for(const file of this.files) {
failedTests ||= await runTest(file)
}
this.value=""
if (!failedTests) {
console.log("All tests passed in " + ((Date.now() - startTime)/1000) + "s")
}
},
});
Object.assign(fileInput, {
type: "file",
accept: ".json",
onchange: async function() {
if (this.files.length) {
if(!(await runTest(this.files[0]))) {
console.log("Test complete")
}
this.value=""
}
},
});
}}
app.extensionManager.registerSidebarTab(testSidebar)
+1
View File
@@ -0,0 +1 @@
Workflows for automated testing of VHS. Most include an additional tests key to check the properties or perform comparisons on node outputs
+313
View File
@@ -0,0 +1,313 @@
{
"id": "07b812b5-5037-4878-90bc-32d3a1f36619",
"revision": 0,
"last_node_id": 7,
"last_link_id": 5,
"nodes": [
{
"id": 5,
"type": "VHS_VideoCombine",
"pos": [
732,
-23
],
"size": [
210,
334
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
},
{
"name": "audio",
"shape": 7,
"type": "AUDIO",
"link": 3
},
{
"name": "meta_batch",
"shape": 7,
"type": "VHS_BatchManager",
"link": null
},
{
"name": "vae",
"shape": 7,
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "Filenames",
"type": "VHS_FILENAMES",
"links": null
}
],
"properties": {
"Node name for S&R": "VHS_VideoCombine"
},
"widgets_values": {
"frame_rate": 8,
"loop_count": 0,
"filename_prefix": "AnimateDiff",
"format": "video/webm",
"pix_fmt": "yuv420p",
"crf": 20,
"save_metadata": true,
"trim_to_audio": false,
"pingpong": false,
"save_output": false,
"videopreview": {
"hidden": false,
"paused": false,
"params": {}
}
}
},
{
"id": 6,
"type": "VHS_VideoCombine",
"pos": [
503,
363
],
"size": [
210,
334
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 4
},
{
"name": "audio",
"shape": 7,
"type": "AUDIO",
"link": 5
},
{
"name": "meta_batch",
"shape": 7,
"type": "VHS_BatchManager",
"link": null
},
{
"name": "vae",
"shape": 7,
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "Filenames",
"type": "VHS_FILENAMES",
"links": null
}
],
"properties": {
"Node name for S&R": "VHS_VideoCombine"
},
"widgets_values": {
"frame_rate": 8,
"loop_count": 0,
"filename_prefix": "AnimateDiff",
"format": "video/h264-mp4",
"pix_fmt": "yuv420p",
"crf": 19,
"save_metadata": true,
"trim_to_audio": false,
"pingpong": false,
"save_output": false,
"videopreview": {
"hidden": false,
"paused": false,
"params": {}
}
}
},
{
"id": 4,
"type": "VHS_LoadVideoPath",
"pos": [
29,
16
],
"size": [
221.27618408203125,
413.1552734375
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "meta_batch",
"shape": 7,
"type": "VHS_BatchManager",
"link": null
},
{
"name": "vae",
"shape": 7,
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
2,
4
]
},
{
"name": "frame_count",
"type": "INT",
"links": null
},
{
"name": "audio",
"type": "AUDIO",
"links": [
3
]
},
{
"name": "video_info",
"type": "VHS_VIDEOINFO",
"links": null
}
],
"properties": {
"Node name for S&R": "VHS_LoadVideoPath"
},
"widgets_values": {
"video": "input/bigbuckbunny.mp4",
"force_rate": 8,
"custom_width": 0,
"custom_height": 0,
"frame_load_cap": 30,
"skip_first_frames": 0,
"select_every_nth": 1,
"format": "AnimateDiff",
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"filename": "input/bigbuckbunny.mp4",
"type": "path",
"format": "video/mp4",
"force_rate": 8,
"custom_width": 0,
"custom_height": 0,
"frame_load_cap": 30,
"skip_first_frames": 0,
"select_every_nth": 1
}
}
}
},
{
"id": 7,
"type": "VHS_LoadAudio",
"pos": [
83,
564
],
"size": [
218.93820190429688,
126
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "audio",
"type": "AUDIO",
"links": [
5
]
},
{
"name": "duration",
"type": "FLOAT",
"links": null
}
],
"properties": {
"Node name for S&R": "VHS_LoadAudio"
},
"widgets_values": {
"audio_file": "input/bigbuckbunny.mp4",
"seek_seconds": 0,
"duration": 5
}
}
],
"links": [
[
2,
4,
0,
5,
0,
"IMAGE"
],
[
3,
4,
2,
5,
1,
"AUDIO"
],
[
4,
4,
0,
6,
0,
"IMAGE"
],
[
5,
7,
0,
6,
1,
"AUDIO"
]
],
"groups": [],
"config": {},
"extra": {
"frontendVersion": "1.25.0",
"VHS_latentpreview": true,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4,
"tests": {
"5": [{"type": "audio", "key": "nb_read_packets", "value": "177"}],
"6": [{"type": "audio", "key": "nb_read_packets", "value": "176"}],
"length": 2
}
}
+535
View File
@@ -0,0 +1,535 @@
{
"last_node_id": 18,
"last_link_id": 28,
"nodes": [
{
"id": 16,
"type": "SolidMask",
"pos": [
38,
1066
],
"size": {
"0": 315,
"1": 106
},
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "MASK",
"type": "MASK",
"links": [
18
],
"slot_index": 0,
"shape": 3
}
],
"properties": {
"Node name for S&R": "SolidMask"
},
"widgets_values": [
1,
512,
512
]
},
{
"id": 15,
"type": "ImageCompositeMasked",
"pos": [
412.0800030517579,
591.4099975585939
],
"size": {
"0": 315,
"1": 146
},
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "destination",
"type": "IMAGE",
"link": 16
},
{
"name": "source",
"type": "IMAGE",
"link": 17
},
{
"name": "mask",
"type": "MASK",
"link": 18
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
19
],
"slot_index": 0,
"shape": 3
}
],
"properties": {
"Node name for S&R": "ImageCompositeMasked"
},
"widgets_values": [
0,
0,
false
]
},
{
"id": 18,
"type": "VHS_BatchManager",
"pos": [
329.8000881958008,
-87.40008056640622
],
"size": {
"0": 315,
"1": 58
},
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "VHS_BatchManager",
"type": "VHS_BatchManager",
"links": [
24,
25,
26,
27
],
"slot_index": 0,
"shape": 3
}
],
"properties": {
"Node name for S&R": "VHS_BatchManager"
},
"widgets_values": {
"frames_per_batch": 4,
"count": 2
}
},
{
"id": 10,
"type": "VHS_LoadVideoPath",
"pos": [
24,
102
],
"size": [
320,
420.7188019966722
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "meta_batch",
"type": "VHS_BatchManager",
"link": 25,
"slot_index": 0
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
16,
22
],
"slot_index": 0,
"shape": 3
},
{
"name": "frame_count",
"type": "INT",
"links": null,
"shape": 3
},
{
"name": "audio",
"type": "AUDIO",
"links": [
28
],
"slot_index": 2,
"shape": 3
},
{
"name": "video_info",
"type": "VHS_VIDEOINFO",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "VHS_LoadVideoPath"
},
"widgets_values": {
"video": "input/bigbuckbunny.mp4",
"force_rate": 8,
"force_size": "512x?",
"custom_width": 512,
"custom_height": 512,
"frame_load_cap": 16,
"skip_first_frames": 64,
"select_every_nth": 1,
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"frame_load_cap": 16,
"skip_first_frames": 64,
"force_rate": 8,
"filename": "input/bigbuckbunny.mp4",
"type": "path",
"format": "video/mp4",
"select_every_nth": 1,
"force_size": "512x?"
}
}
}
},
{
"id": 14,
"type": "VHS_LoadVideoPath",
"pos": [
26,
598
],
"size": [
320,
444.7188019966722
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "meta_batch",
"type": "VHS_BatchManager",
"link": 26,
"slot_index": 0
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
17
],
"slot_index": 0,
"shape": 3
},
{
"name": "frame_count",
"type": "INT",
"links": null,
"shape": 3
},
{
"name": "audio",
"type": "VHS_AUDIO",
"links": [],
"slot_index": 2,
"shape": 3
},
{
"name": "video_info",
"type": "VHS_VIDEOINFO",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "VHS_LoadVideoPath"
},
"widgets_values": {
"video": "input/bigbuckbunny.mp4",
"force_rate": 8,
"force_size": "Custom Width",
"custom_width": 384,
"custom_height": 512,
"frame_load_cap": 16,
"skip_first_frames": 64,
"select_every_nth": 1,
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"frame_load_cap": 16,
"skip_first_frames": 64,
"force_rate": 8,
"filename": "input/bigbuckbunny.mp4",
"type": "path",
"format": "video/mp4",
"select_every_nth": 1,
"force_size": "512x?"
}
}
}
},
{
"id": 11,
"type": "VHS_VideoCombine",
"pos": [
762,
646
],
"size": [
320,
492.75
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 19
},
{
"name": "audio",
"type": "AUDIO",
"link": 28
},
{
"name": "meta_batch",
"type": "VHS_BatchManager",
"link": 27,
"slot_index": 2
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "Filenames",
"type": "VHS_FILENAMES",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "VHS_VideoCombine"
},
"widgets_values": {
"frame_rate": 8,
"loop_count": 0,
"filename_prefix": "AnimateDiff",
"format": "video/h264-mp4",
"pix_fmt": "yuv420p",
"crf": 19,
"save_metadata": true,
"pingpong": false,
"save_output": false,
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"filename": "AnimateDiff_00005-audio.mp4",
"subfolder": "",
"type": "temp",
"format": "video/h264-mp4",
"frame_rate": 8
}
}
}
},
{
"id": 17,
"type": "VHS_VideoCombine",
"pos": [
756,
100
],
"size": [
320,
492.75
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 22
},
{
"name": "audio",
"type": "VHS_AUDIO",
"link": null
},
{
"name": "meta_batch",
"type": "VHS_BatchManager",
"link": 24,
"slot_index": 2
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "Filenames",
"type": "VHS_FILENAMES",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "VHS_VideoCombine"
},
"widgets_values": {
"frame_rate": 8,
"loop_count": 0,
"filename_prefix": "AnimateDiff",
"format": "video/webm",
"pix_fmt": "yuv420p",
"crf": 20,
"save_metadata": true,
"pingpong": false,
"save_output": false,
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"filename": "AnimateDiff_00004.webm",
"subfolder": "",
"type": "temp",
"format": "video/webm",
"frame_rate": 8
}
}
}
}
],
"links": [
[
16,
10,
0,
15,
0,
"IMAGE"
],
[
17,
14,
0,
15,
1,
"IMAGE"
],
[
18,
16,
0,
15,
2,
"MASK"
],
[
19,
15,
0,
11,
0,
"IMAGE"
],
[
22,
10,
0,
17,
0,
"IMAGE"
],
[
24,
18,
0,
17,
2,
"VHS_BatchManager"
],
[
25,
18,
0,
10,
0,
"VHS_BatchManager"
],
[
26,
18,
0,
14,
0,
"VHS_BatchManager"
],
[
27,
18,
0,
11,
2,
"VHS_BatchManager"
],
[
28,
10,
2,
11,
1,
"VHS_AUDIO"
]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4,
"tests": {
"17": [{"type": "video", "key": "nb_read_packets", "value": "16"}],
"11": [{"type": "video", "key": "nb_read_packets", "value": "16"},
{"type": "compare", "filename": "custom_nodes/ComfyUI-VideoHelperSuite/tests/outputs/batch.mp4", "tolerance": 0.02}
],
"length": 1
}
}
+253
View File
@@ -0,0 +1,253 @@
{
"last_node_id": 19,
"last_link_id": 20,
"nodes": [
{
"id": 18,
"type": "PrimitiveNode",
"pos": [
318,
618
],
"size": [
210,
82
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [
18
],
"widget": {
"name": "crf"
}
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [
60,
"fixed"
]
},
{
"id": 11,
"type": "LoadImage",
"pos": [
260.4530029296875,
233.2003173828125
],
"size": [
315,
314
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
19
],
"slot_index": 0
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"example.png",
"image"
]
},
{
"id": 17,
"type": "VHS_VideoCombine",
"pos": [
733.3749389648438,
338.28924560546875
],
"size": [
222.91415405273438,
522.9141845703125
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 19
},
{
"name": "audio",
"type": "AUDIO",
"link": null,
"shape": 7
},
{
"name": "meta_batch",
"type": "VHS_BatchManager",
"link": null,
"shape": 7
},
{
"name": "vae",
"type": "VAE",
"link": null,
"shape": 7
},
{
"name": "crf",
"type": "INT",
"link": 18,
"widget": {
"name": "crf"
}
},
{
"name": "pix_fmt",
"type": [
"yuv420p",
"yuv420p10le"
],
"link": 20,
"widget": {
"name": "pix_fmt"
}
}
],
"outputs": [
{
"name": "Filenames",
"type": "VHS_FILENAMES",
"links": null
}
],
"properties": {
"Node name for S&R": "VHS_VideoCombine"
},
"widgets_values": {
"frame_rate": 8,
"loop_count": 0,
"filename_prefix": "AnimateDiff",
"format": "video/h264-mp4",
"pix_fmt": "yuv420p10le",
"crf": 60,
"save_metadata": true,
"pingpong": false,
"save_output": false,
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"filename": "AnimateDiff_00001.mp4",
"subfolder": "",
"type": "temp",
"format": "video/h264-mp4",
"frame_rate": 8
},
"muted": false
}
}
},
{
"id": 19,
"type": "PrimitiveNode",
"pos": [
300,
760
],
"size": [
290,
110
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "COMBO",
"type": "COMBO",
"links": [
20
],
"widget": {
"name": "pix_fmt"
}
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [
"yuv420p10le",
"fixed",
""
]
}
],
"links": [
[
18,
18,
0,
17,
4,
"INT"
],
[
19,
11,
0,
17,
0,
"IMAGE"
],
[
20,
19,
0,
17,
5,
[
"yuv420p",
"yuv420p10le"
]
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.8264462809917354,
"offset": [
45.8650452880864,
-157.46987175292935
]
}
},
"version": 0.4,
"tests": {
"17": [{"type": "video", "key": "pix_fmt", "value": "yuv420p10le"}
],
"length": 1
}
}
+307
View File
@@ -0,0 +1,307 @@
{
"last_node_id": 20,
"last_link_id": 23,
"nodes": [
{
"id": 18,
"type": "VHS_VideoInfoLoaded",
"pos": [
424,
427
],
"size": {
"0": 304.79998779296875,
"1": 106
},
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "video_info",
"type": "VHS_VIDEOINFO",
"link": 19
}
],
"outputs": [
{
"name": "fps🟦",
"type": "FLOAT",
"links": [
20
],
"slot_index": 0,
"shape": 3
},
{
"name": "frame_count🟦",
"type": "INT",
"links": null,
"shape": 3
},
{
"name": "duration🟦",
"type": "FLOAT",
"links": null,
"shape": 3
},
{
"name": "width🟦",
"type": "INT",
"links": null,
"shape": 3
},
{
"name": "height🟦",
"type": "INT",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "VHS_VideoInfoLoaded"
},
"widgets_values": {}
},
{
"id": 17,
"type": "VHS_VideoCombine",
"pos": [
783,
223
],
"size": [
315,
286
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 21
},
{
"name": "audio",
"type": "VHS_AUDIO",
"link": null
},
{
"name": "meta_batch",
"type": "VHS_BatchManager",
"link": null
},
{
"name": "frame_rate",
"type": "FLOAT",
"link": 20,
"widget": {
"name": "frame_rate"
}
}
],
"outputs": [
{
"name": "Filenames",
"type": "VHS_FILENAMES",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "VHS_VideoCombine"
},
"widgets_values": {
"frame_rate": 8,
"loop_count": 0,
"filename_prefix": "AnimateDiff",
"format": "video/h264-mp4",
"pix_fmt": "yuv420p",
"crf": 19,
"save_metadata": true,
"pingpong": false,
"save_output": false,
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"filename": "AnimateDiff_00001.mp4",
"subfolder": "",
"type": "temp",
"format": "video/h264-mp4"
}
}
}
},
{
"id": 16,
"type": "VHS_LoadVideoPath",
"pos": [
96,
230
],
"size": [
315,
449.5
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "meta_batch",
"type": "VHS_BatchManager",
"link": null
},
{
"name": "frame_load_cap",
"type": "INT",
"link": 23,
"slot_index": 1,
"widget": {
"name": "frame_load_cap"
}
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
21
],
"slot_index": 0,
"shape": 3
},
{
"name": "frame_count",
"type": "INT",
"links": null,
"shape": 3
},
{
"name": "audio",
"type": "VHS_AUDIO",
"links": null,
"shape": 3
},
{
"name": "video_info",
"type": "VHS_VIDEOINFO",
"links": [
19
],
"slot_index": 3,
"shape": 3
}
],
"properties": {
"Node name for S&R": "VHS_LoadVideoPath"
},
"widgets_values": {
"video": "input/leader.webm",
"force_rate": 0,
"force_size": "Disabled",
"custom_width": 512,
"custom_height": 512,
"frame_load_cap": 64,
"skip_first_frames": 0,
"select_every_nth": 1,
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"frame_load_cap": 64,
"skip_first_frames": 0,
"force_rate": 0,
"filename": "input/leader.webm",
"type": "path",
"format": "video/webm",
"select_every_nth": 1
}
}
}
},
{
"id": 20,
"type": "PrimitiveNode",
"pos": [
-240,
170
],
"size": {
"0": 210,
"1": 80
},
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [
23
],
"widget": {
"name": "frame_load_cap"
}
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [
64,
"fixed"
]
}
],
"links": [
[
19,
16,
3,
18,
0,
"VHS_VIDEOINFO"
],
[
20,
18,
0,
17,
3,
"FLOAT"
],
[
21,
16,
0,
17,
0,
"IMAGE"
],
[
23,
20,
0,
16,
1,
"INT"
]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4,
"tests": {
"17": [{"type": "video", "key": "width", "value": 1440},
{"type": "video", "key": "height", "value": 1080},
{"type": "video", "key": "nb_read_packets", "value": "64"}
],
"length": 1
}
}
+178
View File
@@ -0,0 +1,178 @@
{
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "VHS_LoadVideo",
"pos": {
"0": 54,
"1": 89
},
"size": [
260,
460
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "meta_batch",
"type": "VHS_BatchManager",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
1
],
"slot_index": 0,
"shape": 3
},
{
"name": "frame_count",
"type": "INT",
"links": null,
"shape": 3
},
{
"name": "audio",
"type": "VHS_AUDIO",
"links": null,
"shape": 3
},
{
"name": "video_info",
"type": "VHS_VIDEOINFO",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "VHS_LoadVideo"
},
"widgets_values": {
"video": "leader.webm",
"force_rate": 8,
"force_size": "Disabled",
"custom_width": 304,
"custom_height": 312,
"frame_load_cap": 16,
"skip_first_frames": 1,
"select_every_nth": 1,
"choose video to upload": "image",
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"frame_load_cap": 16,
"skip_first_frames": 1,
"force_rate": 8,
"filename": "leader.webm",
"type": "input",
"format": "video/mp4",
"force_size": "410.4x?",
"select_every_nth": 1
}
}
}
},
{
"id": 3,
"type": "VHS_VideoCombine",
"pos": {
"0": 629,
"1": 222
},
"size": [
320,
550
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
},
{
"name": "audio",
"type": "AUDIO",
"link": null
},
{
"name": "meta_batch",
"type": "VHS_BatchManager",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "Filenames",
"type": "VHS_FILENAMES",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "VHS_VideoCombine"
},
"widgets_values": {
"frame_rate": 8,
"loop_count": 1,
"filename_prefix": "AnimateDiff",
"format": "video/h264-mp4",
"pix_fmt": "yuv420p",
"crf": 19,
"save_metadata": true,
"pingpong": false,
"save_output": false,
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"filename": "AnimateDiff_00005.mp4",
"subfolder": "",
"type": "temp",
"format": "video/h264-mp4",
"frame_rate": 8
}
}
}
}
],
"links": [
[
1,
1,
0,
3,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4,
"tests": {
"3": [{"type": "video", "key": "nb_read_packets", "value": "32"}],
"length": 1
}
}
+423
View File
@@ -0,0 +1,423 @@
{
"id": "bc7448ba-118f-4226-95ab-32227992f954",
"revision": 0,
"last_node_id": 6,
"last_link_id": 4,
"nodes": [
{
"id": 1,
"type": "VHS_LoadVideo",
"pos": [
54,
89
],
"size": [
245.1999969482422,
492.7751770019531
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "meta_batch",
"shape": 7,
"type": "VHS_BatchManager",
"link": null
},
{
"name": "vae",
"shape": 7,
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
1,
2,
3,
4
]
},
{
"name": "frame_count",
"type": "INT",
"links": null
},
{
"name": "audio",
"type": "AUDIO",
"links": null
},
{
"name": "video_info",
"type": "VHS_VIDEOINFO",
"links": null
}
],
"properties": {
"Node name for S&R": "VHS_LoadVideo"
},
"widgets_values": {
"video": "leader.webm",
"force_rate": 8,
"custom_width": 512,
"custom_height": 0,
"frame_load_cap": 64,
"skip_first_frames": 1,
"select_every_nth": 1,
"format": "AnimateDiff",
"choose video to upload": "image",
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"frame_load_cap": 64,
"skip_first_frames": 0,
"force_rate": 8,
"filename": "leader.webm",
"type": "input",
"format": "video/mp4",
"select_every_nth": 1
}
}
}
},
{
"id": 4,
"type": "VHS_VideoCombine",
"pos": [
630.9500122070312,
136.90997314453125
],
"size": [
315,
497.25
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
},
{
"name": "audio",
"shape": 7,
"type": "AUDIO",
"link": null
},
{
"name": "meta_batch",
"shape": 7,
"type": "VHS_BatchManager",
"link": null
},
{
"name": "vae",
"shape": 7,
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "Filenames",
"type": "VHS_FILENAMES",
"links": null
}
],
"properties": {
"Node name for S&R": "VHS_VideoCombine"
},
"widgets_values": {
"frame_rate": 8,
"loop_count": 0,
"filename_prefix": "AnimateDiff",
"format": "video/ProRes",
"profile": "2",
"pingpong": false,
"save_output": false,
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"filename": "AnimateDiff_00004.mov",
"subfolder": "",
"type": "temp",
"format": "video/ProRes",
"frame_rate": 8,
"workflow": "AnimateDiff_00004.png"
}
}
}
},
{
"id": 3,
"type": "VHS_VideoCombine",
"pos": [
635.0499267578125,
-407.1000061035156
],
"size": [
315,
497.25
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
},
{
"name": "audio",
"shape": 7,
"type": "AUDIO",
"link": null
},
{
"name": "meta_batch",
"shape": 7,
"type": "VHS_BatchManager",
"link": null
},
{
"name": "vae",
"shape": 7,
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "Filenames",
"type": "VHS_FILENAMES",
"links": null
}
],
"properties": {
"Node name for S&R": "VHS_VideoCombine"
},
"widgets_values": {
"frame_rate": 8,
"loop_count": 0,
"filename_prefix": "AnimateDiff",
"format": "video/ProRes",
"profile": "1",
"pingpong": false,
"save_output": false,
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"filename": "AnimateDiff_00001.mov",
"subfolder": "",
"type": "temp",
"format": "video/ProRes",
"frame_rate": 8,
"workflow": "AnimateDiff_00001.png"
}
}
}
},
{
"id": 5,
"type": "VHS_VideoCombine",
"pos": [
974.6401977539062,
-409.33984375
],
"size": [
315,
497.25
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 3
},
{
"name": "audio",
"shape": 7,
"type": "AUDIO",
"link": null
},
{
"name": "meta_batch",
"shape": 7,
"type": "VHS_BatchManager",
"link": null
},
{
"name": "vae",
"shape": 7,
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "Filenames",
"type": "VHS_FILENAMES",
"links": null
}
],
"properties": {
"Node name for S&R": "VHS_VideoCombine"
},
"widgets_values": {
"frame_rate": 8,
"loop_count": 0,
"filename_prefix": "AnimateDiff",
"format": "video/ProRes",
"profile": "3",
"pingpong": false,
"save_output": false,
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"filename": "AnimateDiff_00002.mov",
"subfolder": "",
"type": "temp",
"format": "video/ProRes",
"frame_rate": 8,
"workflow": "AnimateDiff_00002.png"
}
}
}
},
{
"id": 6,
"type": "VHS_VideoCombine",
"pos": [
968.7000122070312,
138.7698974609375
],
"size": [
315,
497.25
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 4
},
{
"name": "audio",
"shape": 7,
"type": "AUDIO",
"link": null
},
{
"name": "meta_batch",
"shape": 7,
"type": "VHS_BatchManager",
"link": null
},
{
"name": "vae",
"shape": 7,
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "Filenames",
"type": "VHS_FILENAMES",
"links": null
}
],
"properties": {
"Node name for S&R": "VHS_VideoCombine"
},
"widgets_values": {
"frame_rate": 8,
"loop_count": 0,
"filename_prefix": "AnimateDiff",
"format": "video/ProRes",
"profile": "4",
"pingpong": false,
"save_output": false,
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"filename": "AnimateDiff_00003.mov",
"subfolder": "",
"type": "temp",
"format": "video/ProRes",
"frame_rate": 8
}
}
}
}
],
"links": [
[
1,
1,
0,
3,
0,
"IMAGE"
],
[
2,
1,
0,
4,
0,
"IMAGE"
],
[
3,
1,
0,
5,
0,
"IMAGE"
],
[
4,
1,
0,
6,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"frontendVersion": "1.17.0",
"VHS_latentpreview": true,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4,
"tests": {
"6": [{"type": "video", "key": "pix_fmt", "value": "yuv444p12le"}],
"length": 1
}
}
+229
View File
@@ -0,0 +1,229 @@
{
"last_node_id": 10,
"last_link_id": 9,
"nodes": [
{
"id": 7,
"type": "VHS_VideoCombine",
"pos": [
746,
309
],
"size": [
320,
468.7188019966722
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "LATENT",
"link": 9
},
{
"name": "audio",
"type": "AUDIO",
"link": null
},
{
"name": "meta_batch",
"type": "VHS_BatchManager",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "Filenames",
"type": "VHS_FILENAMES",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "VHS_VideoCombine"
},
"widgets_values": {
"frame_rate": 8,
"loop_count": 0,
"filename_prefix": "AnimateDiff",
"format": "video/webm",
"crf": 20,
"save_metadata": true,
"pingpong": false,
"save_output": false,
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"filename": "AnimateDiff_00001.webm",
"subfolder": "",
"type": "temp",
"format": "video/webm",
"frame_rate": 8
}
}
}
},
{
"id": 6,
"type": "VHS_LoadVideoPath",
"pos": [
171,
239
],
"size": [
320,
420.7188019966722
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "meta_batch",
"type": "VHS_BatchManager",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": 7,
"slot_index": 1
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
9
],
"slot_index": 0,
"shape": 3
},
{
"name": "frame_count",
"type": "INT",
"links": null,
"shape": 3
},
{
"name": "audio",
"type": "AUDIO",
"links": [],
"slot_index": 2,
"shape": 3
},
{
"name": "video_info",
"type": "VHS_VIDEOINFO",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "VHS_LoadVideoPath"
},
"widgets_values": {
"video": "input/bigbuckbunny.mp4",
"force_rate": 8,
"force_size": "Disabled",
"custom_width": 512,
"custom_height": 512,
"frame_load_cap": 64,
"skip_first_frames": 0,
"select_every_nth": 1,
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"frame_load_cap": 64,
"skip_first_frames": 0,
"force_rate": 8,
"filename": "input/bigbuckbunny.mp4",
"type": "path",
"format": "video/mp4",
"select_every_nth": 1
}
}
}
},
{
"id": 10,
"type": "VAELoader",
"pos": [
-42,
88
],
"size": {
"0": 315,
"1": 58
},
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "VAE",
"type": "VAE",
"links": [
7,
8
],
"shape": 3,
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "VAELoader"
},
"widgets_values": [
"taesd"
]
}
],
"links": [
[
7,
10,
0,
6,
1,
"VAE"
],
[
8,
10,
0,
7,
3,
"VAE"
],
[
9,
6,
0,
7,
0,
"LATENT"
]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4,
"tests": {
"7": [{"type": "video", "key": "width", "value": 1920},
{"type": "video", "key": "height", "value": 1080},
{"type": "video", "key": "nb_read_packets", "value": "64"}
],
"length": 1
}
}
+134
View File
@@ -0,0 +1,134 @@
{
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "VHS_LoadVideo",
"pos": [
54,
89
],
"size": [
235.1999969482422,
384.56999829610186
],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
1
],
"shape": 3,
"slot_index": 0
},
{
"name": "frame_count",
"type": "INT",
"links": null,
"shape": 3
},
{
"name": "audio",
"type": "VHS_AUDIO",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "VHS_LoadVideo"
},
"widgets_values": {
"video": "leader.webm",
"force_rate": 8,
"force_size": "Custom Width",
"custom_width": 304,
"custom_height": 312,
"frame_load_cap": 16,
"skip_first_frames": 1,
"select_every_nth": 1,
"choose video to upload": "image",
"videopreview": {
"hidden": false,
"paused": false,
"params": {
"frame_load_cap": 0,
"skip_first_frames": 0,
"force_rate": 0,
"filename": "leader.webm",
"type": "input",
"format": "video/mp4",
"force_size": "410.4x?",
"select_every_nth": 1
}
}
}
},
{
"id": 3,
"type": "VHS_VideoCombine",
"pos": [
629,
222
],
"size": {
"0": 315,
"1": 250
},
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1
}
],
"outputs": [],
"properties": {
"Node name for S&R": "VHS_VideoCombine"
},
"widgets_values": {
"frame_rate": 8,
"loop_count": 0,
"filename_prefix": "AnimateDiff",
"format": "video/webm",
"pingpong": false,
"save_image": false,
"crf": 20,
"save_metadata": false,
"audio_file": "",
"videopreview": {
"hidden": false,
"paused": false
}
}
}
],
"links": [
[
1,
1,
0,
3,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4,
"tests": {
"3": [{"type": "video", "key": "width", "value": 304},
{"type": "video", "key": "height", "value": 232},
{"type": "compare", "filename": "custom_nodes/ComfyUI-VideoHelperSuite/tests/outputs/simple.webm", "tolerance": 0.02}
],
"length": 1
}
}
+15 -3
View File
@@ -2,9 +2,21 @@
"main_pass":
[
"-n", "-c:v", "prores_ks",
"-profile:v","3",
"-pix_fmt", "yuv422p10"
"-profile:v", [["$profile"]],
["profile", {
"lt": [[]], "1": [[]], "standard": [[]], "2": [[]], "hq": [[]], "3": [[]],
"4": ["has_alpha", {"True": [["-pix_fmt", "yuva444p10le"]],
"False": [["-pix_fmt", "yuv444p10le"]]}],
"4444": ["has_alpha", {"True": [["-pix_fmt", "yuva444p10le"]],
"False": [["-pix_fmt", "yuv444p10le"]]}],
"4444xq": ["has_alpha", {"True": [["-pix_fmt", "yuva444p10le"]],
"False": [["-pix_fmt", "yuv444p10le"]]}]
}],
"-vf", "scale=out_color_matrix=bt709",
"-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
],
"fake_trc": "bt709",
"audio_pass": ["-c:a", "pcm_s16le"],
"extension": "mov"
"extension": "mov",
"extra_widgets": [["profile", ["lt", "standard", "hq", "4444", "4444xq"], {"default": "hq"}]]
}
+4 -1
View File
@@ -3,8 +3,11 @@
[
"-n", "-c:v", "libsvtav1",
"-pix_fmt", ["pix_fmt", ["yuv420p10le", "yuv420p"]],
"-crf", ["crf","INT", {"default": 23, "min": 0, "max": 100, "step": 1}]
"-crf", ["crf","INT", {"default": 23, "min": 0, "max": 100, "step": 1}],
"-vf", "scale=out_color_matrix=bt709",
"-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
],
"fake_trc": "bt709",
"audio_pass": ["-c:a", "libopus"],
"input_color_depth": ["input_color_depth", ["8bit", "16bit"]],
"save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
+18
View File
@@ -0,0 +1,18 @@
{
"main_pass": [
"-n",
"-c:v", "ffv1",
"-level", ["level", ["0", "1", "3"], {"default": "3"}],
"-coder", ["coder", ["0", "1", "2"], {"default": "1"}],
"-context", ["context", ["0", "1"], {"default": "1"}],
"-g", ["gop_size", "INT", {"default": 1, "min": 1, "max": 300, "step": 1}],
"-slices", ["slices", ["4", "6", "9", "12", "16", "20", "24", "30"], {"default": "16"}],
"-slicecrc", ["slicecrc", ["0", "1"], {"default": "1"}],
"-pix_fmt", ["pix_fmt", ["rgba64le", "bgra", "yuv420p", "yuv422p", "yuv444p", "yuva420p", "yuva422p", "yuva444p", "yuv420p10le", "yuv422p10le", "yuv444p10le", "yuv420p12le", "yuv422p12le", "yuv444p12le", "yuv420p14le", "yuv422p14le", "yuv444p14le", "yuv420p16le", "yuv422p16le", "yuv444p16le", "gray", "gray10le", "gray12le", "gray16le"], {"default": "rgba64le"}]
],
"audio_pass": ["-c:a", "flac"],
"save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
"trim_to_audio": ["trim_to_audio", "BOOLEAN", {"default": false}],
"input_color_depth": "16bit",
"extension": "mkv"
}
+3 -1
View File
@@ -1,7 +1,9 @@
{
"main_pass":
[
"-pix_fmt", "yuv420p"
"-pix_fmt", "yuv444p",
"-vf", "scale=out_color_matrix=bt709:out_range=pc",
"-color_range", "pc"
],
"extension": "gif",
"gifski_pass": [
+6 -2
View File
@@ -3,9 +3,13 @@
[
"-n", "-c:v", "libx264",
"-pix_fmt", ["pix_fmt", ["yuv420p", "yuv420p10le"]],
"-crf", ["crf","INT", {"default": 19, "min": 0, "max": 100, "step": 1}]
"-crf", ["crf","INT", {"default": 19, "min": 0, "max": 100, "step": 1}],
"-vf", "scale=out_color_matrix=bt709",
"-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
],
"audio_pass": ["-c:a", "aac"],
"fake_trc": "bt709",
"audio_pass": ["-c:a", "aac", "-movflags", "use_metadata_tags"],
"save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
"trim_to_audio": ["trim_to_audio", "BOOLEAN", {"default": false}],
"extension": "mp4"
}
+5 -2
View File
@@ -6,9 +6,12 @@
"-pix_fmt", ["pix_fmt", ["yuv420p10le", "yuv420p"]],
"-crf", ["crf","INT", {"default": 22, "min": 0, "max": 100, "step": 1}],
"-preset", "medium",
"-x265-params", "log-level=quiet"
"-x265-params", "log-level=quiet",
"-vf", "scale=out_color_matrix=bt709",
"-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
],
"audio_pass": ["-c:a", "aac"],
"fake_trc": "bt709",
"audio_pass": ["-c:a", "aac", "-movflags", "use_metadata_tags"],
"save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
"extension": "mp4"
}
+15
View File
@@ -0,0 +1,15 @@
{
"main_pass":
[
"-n", "-c:v", "av1_nvenc",
"-pix_fmt", ["pix_fmt", ["yuv420p", "p010le"]],
"-vf", "scale=out_color_matrix=bt709",
"-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
],
"fake_trc": "bt709",
"audio_pass": ["-c:a", "aac", "-movflags", "use_metadata_tags"],
"bitrate": ["bitrate","INT", {"default": 10, "min": 1, "max": 999, "step": 1 }],
"megabit": ["megabit","BOOLEAN", {"default": true}],
"save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
"extension": "mp4"
}
+5 -2
View File
@@ -2,9 +2,12 @@
"main_pass":
[
"-n", "-c:v", "h264_nvenc",
"-pix_fmt", ["pix_fmt", ["yuv420p", "yuv420p10le"]]
"-pix_fmt", ["pix_fmt", ["yuv420p", "p010le"]],
"-vf", "scale=out_color_matrix=bt709",
"-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
],
"audio_pass": ["-c:a", "aac"],
"fake_trc": "bt709",
"audio_pass": ["-c:a", "aac", "-movflags", "use_metadata_tags"],
"bitrate": ["bitrate","INT", {"default": 10, "min": 1, "max": 999, "step": 1 }],
"megabit": ["megabit","BOOLEAN", {"default": true}],
"save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
+5 -2
View File
@@ -3,9 +3,12 @@
[
"-n", "-c:v", "hevc_nvenc",
"-vtag", "hvc1",
"-pix_fmt", ["pix_fmt", ["yuv420p", "yuv420p10le"]]
"-pix_fmt", ["pix_fmt", ["yuv420p", "p010le"]],
"-vf", "scale=out_color_matrix=bt709",
"-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
],
"audio_pass": ["-c:a", "aac"],
"fake_trc": "bt709",
"audio_pass": ["-c:a", "aac", "-movflags", "use_metadata_tags"],
"bitrate": ["bitrate","INT", {"default": 10, "min": 1, "max": 999, "step": 1 }],
"megabit": ["megabit","BOOLEAN", {"default": true}],
"save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
+6 -2
View File
@@ -2,11 +2,15 @@
"main_pass":
[
"-n",
"-pix_fmt", "yuv420p",
"-pix_fmt", ["pix_fmt",["yuv420p","yuva420p"]],
"-crf", ["crf","INT", {"default": 20, "min": 0, "max": 100, "step": 1}],
"-b:v", "0"
"-b:v", "0",
"-vf", "scale=out_color_matrix=bt709",
"-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
],
"fake_trc": "bt709",
"audio_pass": ["-c:a", "libvorbis"],
"save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
"trim_to_audio": ["trim_to_audio", "BOOLEAN", {"default": false}],
"extension": "webm"
}
+616
View File
@@ -0,0 +1,616 @@
from .logger import logger
def image(src):
return f'<img src={src} loading=lazy style="width: 0px; min-width: 100%">'
def video(src):
return f'<video preload="none" src={src} muted loop controls controlslist="nodownload noremoteplayback noplaybackrate" style="width: 0px; min-width: 100%" class="VHS_loopedvideo">'
def short_desc(desc):
return f'<div id=VHS_shortdesc>{desc}</div>'
def format_each(desc, **kwargs):
if isinstance(desc, dict):
res = {}
for k,v in desc.items():
res[format_each(k, **kwargs)] = format_each(v, **kwargs)
return res
if isinstance(desc, list):
res = []
for v in desc:
res.append(format_each(v, **kwargs))
return res
return desc.format(**kwargs)
def format_type(desc, lower, lowers=None, upper=None, uppers=None, cap=None):
"""Utility function for nodes with image/latent/mask variants"""
if lowers is None:
lowers = lower + 's'
if cap is None:
cap = lower.capitalize()
if upper is None:
upper = lower.upper()
if uppers is None:
uppers = lowers.upper()
return format_each(desc, lower=lower, lowers=lowers, upper=upper, uppers=uppers, cap=cap)
common_descriptions = {
'merge_strategy': [
'Determines what the output resolution will be if input resolutions don\'t match',
{'match A': 'Always use the resolution for A',
'match B': 'Always use the resolution for B',
'match smaller': 'Pick the smaller resolution by area',
'match larger': 'Pick the larger resolution by area',
}],
'scale_method': [
'Determines what method to use if scaling is required',
],
'crop_method': 'When sizes don\'t match, should the resized image have it\'s aspect ratio changed, or be cropped to maintain aspect ratio',
'VHS_PATH': [
'This is a VHS_PATH input. When edited, it provides a list of possible valid files or directories',
video('https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite/assets/4284322/729b7185-1fca-41d8-bc8d-a770bb2a5ce6'),
'The current top-most completion may be selected with Tab',
'You can navigate up a directory by pressing Ctrl+B (or Ctrl+W if supported by browser)',
'The filter on suggested file types can be disabled by pressing Ctrl+G.',
'If converted to an input, this functions as a string',
],
"GetCount": ['Get {cap} Count 🎥🅥🅗🅢', short_desc('Return the number of {lowers} in an input as an INT'),
{'Inputs': {
'{lowers}': 'The input {lower}',
},
'Outputs': {
'count': 'The number of {lowers} in the input',
},
}],
"SelectEveryNth": ['Select Every Nth {cap} 🎥🅥🅗🅢', short_desc('Keep only 1 {lower} for every interval'),
{'Inputs': {
'{lowers}': 'The input {lower}',
},
'Outputs': {
'{upper}': 'The output {lowers}',
'count': 'The number of {lowers} in the input',
},
'Widgets':{
'select_every_nth': 'The interval from which one frame is kept. 1 means no frames are skipped.',
'skip_first_{lowers}': 'A number of frames which that is skipped from the start. This applies before select_every_nth. As a result, multiple copies of the node can each have a different skip_first_frames to divide the {lower} into groups'
},
}],
}
descriptions = {
'VHS_VideoCombine': ['Video Combine 🎥🅥🅗🅢', short_desc('Combine an image sequence into a video'), {
'Inputs': {
'images': 'The images to be turned into a video',
'audio':'(optional) audio to add to the video',
'meta_batch': '(optional) Connect to a Meta Batch manager to divide extremely long image sequences into sub batches. See the documentation for Meta Batch Manager',
'vae':['(optional) If provided, the node will take latents as input instead of images. This drastically reduces the required RAM (not VRAM) when working with long (100+ frames) sequences',
"Unlike on Load Video, this isn't always a strict upgrade over using a standalone VAE Decode.",
"If you have multiple Video Combine outputs, then the VAE decode will be performed for each output node increasing execution time",
"If you make any change to output settings on the Video Combine (such as changing the output format), the VAE decode will be performed again as the decoded result is (by design) not cached",
]
},
'Widgets':{
'frame_rate': 'The frame rate which will be used for the output video. Consider converting this to an input and connecting this to a Load Video with Video Info(Loaded)->fps. When including audio, failure to properly set this will result in audio desync',
'loop_count': 'The number of additional times the video should repeat. Can cause performance issues when used with long (100+ frames) sequences',
'filename_prefix': 'A prefix to add to the name of the output filename. This can include subfolders or format strings.',
'format': 'The output format to use. Formats starting with, \'image\' are saved with PIL, but formats starting with \'video\' utilize the video_formats system. \'video\' options require ffmpeg and selecting one frequently adds additional options to the node.',
'pingpong': 'Play the video normally, then repeat the video in reverse so that it \'pingpongs\' back and forth. This is frequently used to minimize the appearance of skips on very short animations.',
'save_output': 'Specifies if output files should be saved to the output folder, or the temporary output folder',
'videopreview': 'Displays a preview for the processed result. If advanced previews is enabled, the output is always converted to a format viewable from the browser. If the video has audio, it will also be previewed when moused over. Additional preview options can be accessed with right click.',
},
'Common Format Widgets': {
'crf': 'Determines how much to prioritize quality over filesize. Numbers vary between formats, but on each format that includes it, the default value provides visually loss less output',
'pix_fmt': ['The pixel format to use for output. Alternative options will often have higher quality at the cost of increased file size and reduced compatibility with external software.', {
'yuv420p': 'The most common and default format',
'yuv420p10le': 'Use 10 bit color depth. This can improve color quality when combined with 16bit input color depth',
'yuva420p': 'Include transparency in the output video'
}],
'input_color_depth': 'VHS supports outputting 16bit images. While this produces higher quality output, the difference usually isn\'t visible without postprocessing and it significantly increases file size and processing time.',
'save_metadata': 'Determines if metadata for the workflow should be included in the output video file',
}
}],
'VHS_LoadVideo': ['Load Video 🎥🅥🅗🅢', short_desc('Loads a video from the input folder'),
{'Inputs': {
'meta_batch': '(optional) Connect to a Meta Batch manager to divide extremely long sequences into sub batches. See the documentation for Meta Batch Manager',
'vae': ['(optional) If provided the node will output latents instead of images. This drastically reduces the required RAM (not VRAM) when working with long (100+ frames) sequences',
'Using this is strongly encouraged unless connecting to a node that requires a blue image connection such as Apply Controllnet',
],
},
'Outputs': {
'IMAGE': 'The loaded images',
'frame_count': 'The length of images just returned',
'audio': 'The audio from the loaded video',
'video_info': 'Exposes additional info about the video such as the source frame rate, or the total length',
'LATENT': 'The loaded images pre-converted to latents. Only available when a vae is connected',
},
'Widgets': {
'video': 'The video file to be loaded. Lists all files with a video extension in the ComfyUI/Input folder',
'force_rate': 'Drops or duplicates frames so that the produced output has the target frame rate. Many motion models are trained on videos of a specific frame rate and will give better results if input matches that frame rate. If set to 0, all frames are returned. May give unusual results with inputs that have a variable frame rate like animated gifs. Reducing this value can also greatly reduce the execution time and memory requirements.',
'force_size': 'Previously was used to provide suggested resolutions. Instead, custom_width and custom_height can be disabled by setting to 0.',
'custom_width': 'Allows for an arbitrary width to be entered, cropping to maintain aspect ratio if both are set',
'custom_height': 'Allows for an arbitrary height to be entered, cropping to maintain aspect ratio if both are set',
'frame_load_cap': 'The maximum number of frames to load. If 0, all frames are loaded.',
'skip_first_frames': 'A number of frames which are discarded before producing output.',
'select_every_nth': 'Similar to frame rate. Keeps only the first of every n frames and discard the rest. Has better compatibility with variable frame rate inputs such as gifs. When combined with force_rate, select_every_nth_applies after force_rate so the resulting output has a frame rate equivalent to force_rate/select_every_nth. select_every_nth does not apply to skip_first_frames',
'format': 'Updates other widgets so that only values supported by the given format can be entered and provides recommended defaults.',
'choose video to upload': 'An upload button is provided to upload local files to the input folder',
'videopreview': 'Displays a preview for the selected video input. If advanced previews is enabled, this preview will reflect the frame_load_cap, force_rate, skip_first_frames, and select_every_nth values chosen. If the video has audio, it will also be previewed when moused over. Additional preview options can be accessed with right click.',
}
}],
'VHS_LoadVideoFFmpeg': ['Load Video FFmpeg 🎥🅥🅗🅢', short_desc('Loads a video from the input folder using ffmpeg instead of opencv'),
'Provides faster execution speed, transparency support, and allows specifying start time in seconds',
{'Inputs': {
'meta_batch': '(optional) Connect to a Meta Batch manager to divide extremely long sequences into sub batches. See the documentation for Meta Batch Manager',
'vae': ['(optional) If provided the node will output latents instead of images. This drastically reduces the required RAM (not VRAM) when working with long (100+ frames) sequences',
'Using this is strongly encouraged unless connecting to a node that requires a blue image connection such as Apply Controllnet',
],
},
'Outputs': {
'IMAGE': 'The loaded images',
'mask': 'Transparency data from the loaded video',
'audio': 'The audio from the loaded video',
'video_info': 'Exposes additional info about the video such as the source frame rate, or the total length',
'LATENT': 'The loaded images pre-converted to latents. Only available when a vae is connected',
},
'Widgets': {
'video': 'The video file to be loaded. Lists all files with a video extension in the ComfyUI/Input folder',
'force_rate': 'Drops or duplicates frames so that the produced output has the target frame rate. Many motion models are trained on videos of a specific frame rate and will give better results if input matches that frame rate. If set to 0, all frames are returned. May give unusual results with inputs that have a variable frame rate like animated gifs. Reducing this value can also greatly reduce the execution time and memory requirements.',
'force_size': 'Previously was used to provide suggested resolutions. Instead, custom_width and custom_height can be disabled by setting to 0.',
'custom_width': 'Allows for an arbitrary width to be entered, cropping to maintain aspect ratio if both are set',
'custom_height': 'Allows for an arbitrary height to be entered, cropping to maintain aspect ratio if both are set',
'frame_load_cap': 'The maximum number of frames to load. If 0, all frames are loaded.',
'start_time': 'A timestamp, in seconds from the start of the video, to start loading frames from. ',
'format': 'Updates other widgets so that only values supported by the given format can be entered and provides recommended defaults.',
'choose video to upload': 'An upload button is provided to upload local files to the input folder',
'videopreview': 'Displays a preview for the selected video input. If advanced previews is enabled, this preview will reflect the frame_load_cap, force_rate, skip_first_frames, and select_every_nth values chosen. If the video has audio, it will also be previewed when moused over. Additional preview options can be accessed with right click.',
}
}],
'VHS_LoadVideoPath': ['Load Video (Path) 🎥🅥🅗🅢', short_desc('Loads a video from an arbitrary path'),
{'Inputs': {
'meta_batch': '(optional) Connect to a Meta Batch manager to divide extremely long sequences into sub batches. See the documentation for Meta Batch Manager',
'vae': ['(optional) If provided the node will output latents instead of images. This drastically reduces the required RAM (not VRAM) when working with long (100+ frames) sequences',
'Using this is strongly encouraged unless connecting to a node that requires a blue image connection such as Apply Controllnet',
],
},
'Outputs': {
'IMAGE': 'The loaded images',
'frame_count': 'The length of images just returned',
'audio': 'The audio from the loaded video',
'video_info': 'Exposes additional info about the video such as the source frame rate, or the total length',
'LATENT': 'The loaded images pre-converted to latents. Only available when a vae is connected',
},
'Widgets': {
'video': ['The video file to be loaded.', 'You can also select an image to load it as a single frame'] + common_descriptions['VHS_PATH'],
'force_rate': 'Drops or duplicates frames so that the produced output has the target frame rate. Many motion models are trained on videos of a specific frame rate and will give better results if input matches that frame rate. If set to 0, all frames are returned. May give unusual results with inputs that have a variable frame rate like animated gifs. Reducing this value can also greatly reduce the execution time and memory requirements.',
'force_size': 'Previously was used to provide suggested resolutions. Instead, custom_width and custom_height can be disabled by setting to 0.',
'custom_width': 'Allows for an arbitrary width to be entered, cropping to maintain aspect ratio if both are set',
'custom_height': 'Allows for an arbitrary height to be entered, cropping to maintain aspect ratio if both are set',
'frame_load_cap': 'The maximum number of frames to load. If 0, all frames are loaded.',
'skip_first_frames': 'A number of frames which are discarded before producing output.',
'select_every_nth': 'Similar to frame rate. Keeps only the first of every n frames and discard the rest. Has better compatibility with variable frame rate inputs such as gifs. When combined with force_rate, select_every_nth_applies after force_rate so the resulting output has a frame rate equivalent to force_rate/select_every_nth. select_every_nth does not apply to skip_first_frames',
'format': 'Updates other widgets so that only values supported by the given format can be entered and provides recommended defaults.',
'videopreview': 'Displays a preview for the selected video input. Will only be shown if Advanced Previews is enabled. This preview will reflect the frame_load_cap, force_rate, skip_first_frames, and select_every_nth values chosen. If the video has audio, it will also be previewed when moused over. Additional preview options can be accessed with right click.',
}
}],
'VHS_LoadVideoFFmpegPath': ['Load Video FFmpeg (Path) 🎥🅥🅗🅢', short_desc('Loads a video from an arbitrary path using ffmpeg instead of opencv'),
'Provides faster execution speed, transparency support, and allows specifying start time in seconds',
{'Inputs': {
'meta_batch': '(optional) Connect to a Meta Batch manager to divide extremely long sequences into sub batches. See the documentation for Meta Batch Manager',
'vae': ['(optional) If provided the node will output latents instead of images. This drastically reduces the required RAM (not VRAM) when working with long (100+ frames) sequences',
'Using this is strongly encouraged unless connecting to a node that requires a blue image connection such as Apply Controllnet',
],
},
'Outputs': {
'IMAGE': 'The loaded images',
'mask': 'Transparency data from the loaded video',
'audio': 'The audio from the loaded video',
'video_info': 'Exposes additional info about the video such as the source frame rate, or the total length',
'LATENT': 'The loaded images pre-converted to latents. Only available when a vae is connected',
},
'Widgets': {
'video': ['The video file to be loaded.', 'You can also select an image to load it as a single frame'] + common_descriptions['VHS_PATH'],
'force_rate': 'Drops or duplicates frames so that the produced output has the target frame rate. Many motion models are trained on videos of a specific frame rate and will give better results if input matches that frame rate. If set to 0, all frames are returned. May give unusual results with inputs that have a variable frame rate like animated gifs. Reducing this value can also greatly reduce the execution time and memory requirements.',
'force_size': 'Previously was used to provide suggested resolutions. Instead, custom_width and custom_height can be disabled by setting to 0.',
'custom_width': 'Allows for an arbitrary width to be entered, cropping to maintain aspect ratio if both are set',
'custom_height': 'Allows for an arbitrary height to be entered, cropping to maintain aspect ratio if both are set',
'frame_load_cap': 'The maximum number of frames to load. If 0, all frames are loaded.',
'skip_first_frames': 'A number of frames which are discarded before producing output.',
'select_every_nth': 'Similar to frame rate. Keeps only the first of every n frames and discard the rest. Has better compatibility with variable frame rate inputs such as gifs. When combined with force_rate, select_every_nth_applies after force_rate so the resulting output has a frame rate equivalent to force_rate/select_every_nth. select_every_nth does not apply to skip_first_frames',
'format': 'Updates other widgets so that only values supported by the given format can be entered and provides recommended defaults.',
'videopreview': 'Displays a preview for the selected video input. Will only be shown if Advanced Previews is enabled. This preview will reflect the frame_load_cap, force_rate, skip_first_frames, and select_every_nth values chosen. If the video has audio, it will also be previewed when moused over. Additional preview options can be accessed with right click.',
}
}],
'VHS_LoadImages': ['Load Images 🎥🅥🅗🅢', short_desc('Loads a sequence of images from a subdirectory of the input folder'),
{'Inputs': {
'meta_batch': '(optional) Connect to a Meta Batch manager to divide extremely long sequences into sub batches. See the documentation for Meta Batch Manager',
},
'Outputs': {
'IMAGE': 'The loaded images',
'MASK': 'The alpha channel of the loaded images.',
'frame_count': 'The length of images just returned',
},
'Widgets': {
'directory': 'The directory images will be loaded from. Filtered to process jpg, png, ppm, bmp, tif, and webp files',
'image_load_cap': 'The maximum number of images to load. If 0, all images are loaded.',
'start_time': 'A timestamp, in seconds from the start of the video, to start loading frames from. ',
'choose folder to upload': 'An upload button is provided to upload a local folder containing images to the input folder',
'videopreview': 'Displays a preview for the selected video input. Will only be shown if Advanced Previews is enabled. This preview will reflect the image_load_cap, skip_first_images, and select_every_nth values chosen. Additional preview options can be accessed with right click.',
}
}],
'VHS_LoadImagesPath': ['Load Images (Path) 🎥🅥🅗🅢', short_desc('Loads a sequence of images from an arbitrary path'),
{'Inputs': {
'meta_batch': '(optional) Connect to a Meta Batch manager to divide extremely long sequences into sub batches. See the documentation for Meta Batch Manager',
},
'Outputs': {
'IMAGE': 'The loaded images',
'MASK': 'The alpha channel of the loaded images.',
'frame_count': 'The length of images just returned',
},
'Widgets': {
'directory': ['The directory images will be loaded from. Filtered to process jpg, png, ppm, bmp, tif, and webp files'] + common_descriptions['VHS_PATH'],
'image_load_cap': 'The maximum number of images to load. If 0, all images are loaded.',
'skip_first_images': 'A number of images which are discarded before producing output.',
'select_every_nth': 'Keeps only the first of every n frames and discard the rest.',
'videopreview': 'Displays a preview for the selected video input. Will only be shown if Advanced Previews is enabled. This preview will reflect the image_load_cap, skip_first_images, and select_every_nth values chosen. Additional preview options can be accessed with right click.',
}
}],
'VHS_LoadImagePath': ['Load Image (Path) 🎥🅥🅗🅢', short_desc('Load a single image from a given path'),
{'Inputs': {
'vae': '(optional) If provided the node will output latents instead of images.',
},
'Outputs': {
'IMAGE': 'The loaded images',
'MASK': 'The alpha channel of the loaded images.',
},
'Widgets': {
'image': ['The image file to be loaded.'] + common_descriptions['VHS_PATH'],
'force_size': ['Allows for conveniently scaling the input without requiring an additional node. Provides options to maintain aspect ratio or conveniently target common training formats for Animate Diff', {'custom_width': 'Allows for an arbitrary width to be entered, cropping to maintain aspect ratio if both are set',
'custom_height': 'Allows for an arbitrary height to be entered, cropping to maintain aspect ratio if both are set'}],
'videopreview': 'Displays a preview for the selected video input. Will only be shown if Advanced Previews is enabled. This preview will reflect the image_load_cap, skip_first_images, and select_every_nth values chosen. Additional preview options can be accessed with right click.',
}
}],
"VHS_LoadAudio": ['Load Audio (Path) 🎥🅥🅗🅢', short_desc('Loads an audio file from an arbitrary path'),
{'Outputs': {
'audio': 'The loaded audio',
},
'Widgets': {
'audio_file': ['The audio file to be loaded.'] + common_descriptions['VHS_PATH'],
'seek_seconds': 'An offset from the start of the sound file that the audio should start from',
}
}],
"VHS_LoadAudioUpload": ['Load Audio (Upload) 🎥🅥🅗🅢', short_desc('Loads an audio file from the input directory'),
"Very similar in functionality to the built-in LoadAudio. It was originally added before VHS swapped to use Comfy's internal AUDIO format, but provides the additional options for start time and duration",
{'Outputs': {
'audio': 'The loaded audio',
},
'Widgets': {
'audio': 'The audio file to be loaded.',
'start_time': 'An offset from the start of the sound file that the audio should start from',
'duration': 'A maximum limit for the audio. Disabled if 0',
'choose audio to upload': 'An upload button is provided to upload an audio file to the input folder',
}
}],
"VHS_AudioToVHSAudio": ['Audio to legacy VHS_AUDIO 🎥🅥🅗🅢', short_desc('utility function for compatibility with external nodes'),
"VHS used to use an internal VHS_AUDIO format for routing audio between inputs and outputs. This format was intended to only be used internally and was designed with a focus on performance over ease of use. Since ComfyUI now has an internal AUDIO format, VHS now uses this format. However, some custom node packs were made that are external to both ComfyUI and VHS that use VHS_AUDIO. This node was added so that those external nodes can still function",
{'Inputs': {
'audio': 'An input in the standardized AUDIO format',
},
'Outputs': {
'vhs_audio': 'An output in the legacy VHS_AUDIO format for use with external nodes',
},
}],
"VHS_VHSAudioToAudio": ['Legacy VHS_AUDIO to Audio 🎥🅥🅗🅢', short_desc('utility function for compatibility with external nodes'),
"VHS used to use an internal VHS_AUDIO format for routing audio between inputs and outputs. This format was intended to only be used internally and was designed with a focus on performance over ease of use. Since ComfyUI now has an internal AUDIO format, VHS now uses this format. However, some custom node packs were made that are external to both ComfyUI and VHS that use VHS_AUDIO. This node was added so that those external nodes can still function",
{'Inputs': {
'vhs_audio': 'An input in the legacy VHS_AUDIO format produced by an external node',
},
'Outputs': {
'vhs_audio': 'An output in the standardized AUDIO format',
},
}],
"VHS_PruneOutputs": ['Prune Outputs 🎥🅥🅗🅢', short_desc('Automates deletion of undesired outputs from a Video Combine node.'),
'Video Combine produces a number of file outputs in addition to the final output. Some of these, such as a video file without audio included, are implementation limitations and are not feasible to solve. As an alternative, the Prune Outputs node is added to automate the deletion of these file outputs if they are not desired',
{'Inputs': {
'filenames': 'A connection from a Video Combine node to indicate which outputs should be pruned',
},
'Widgets': {
'options': ['Which files should be deleted',
{'Intermediate': 'Delete any files that were required for intermediate processing but are not the final output, like the no-audio output file when audio is included',
'Intermediate and Utility': 'Delete all produced files that aren\'t the final output, including the first frame png',
}]}
}],
"VHS_BatchManager": ['Meta Batch Manager 🎥🅥🅗🅢', short_desc('Split the processing of a very long video into sets of smaller Meta Batches'),
"The Meta Batch Manager allows for extremely long input videos to be processed when all other methods for fitting the content in RAM fail. It does not effect VRAM usage.",
"It must be connected to at least one Input (a Load Video or Load Images) AND at least one Video Combine",
image("https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite/assets/4284322/7cb3fb7e-59d8-4cb2-a09f-9c6698de8b1f"),
"It functions by holding both the inputs and ouputs open between executions, and automatically requeue's the workflow until one of the inputs is unable to provide additional images.",
"Because each sub execution only contains a subset of the total frames, each sub execution creates a hard window which temporal smoothing can not be applied across. This results in jumps in the output.",
{'Outputs': {
'meta_batch': 'Add all connected nodes to this Meta Batch',
},
'Widgets': {
'frames_per_batch': 'How many frames to process for each sub execution. If loading as image, each frame will use about 50MB of RAM (not VRAM), and this can safely be set in the 100-1000 range, depending on available memory. When loading and combining from latent space (no blue image noodles exist), this value can be much higher, around the 2,000 to 20,000 range',
}
}],
"VHS_VideoInfo": ['Video Info 🎥🅥🅗🅢', short_desc('Splits information on a video into a numerous outputs'),
{'Inputs': {
'video_info': 'A connection to a Load Video node',
},
'Outputs': {
'source_fps🟨': 'The frame rate of the video',
'source_frame_count🟨': 'How many total frames the video contains before accounting for frame rate or select_every_nth',
'source_duration🟨': 'The length of images just returned in seconds',
'source_width🟨': 'The width',
'source_height🟨': 'The height',
'loaded_fps🟦': 'The frame rate after accounting for force_rate and select_every_nth. This output is of particular use as it can be connected to the converted frame_rate input of a Video Combine node to ensure audio remains synchronized.',
'loaded_frame_count🟦': 'The number of frames returned by the current execution. Identical to the frame_count returned by the node itself',
'loaded_duration🟦': 'The duration in seconds of returned images after accounting for frame_load_cap',
'loaded_width🟦': 'The width of the video after scaling. These coordinates are in image space even if loading to latent space',
'loaded_height🟦': 'The height of the video after scaling. These coordinates are in image space even if loading to latent space',
},
}],
"VHS_VideoInfoSource": ['Video Info Source 🎥🅥🅗🅢', short_desc('Splits information on a video into a numerous outputs describing the file itself without accounting for load options'),
{'Inputs': {
'video_info': 'A connection to a Load Video node',
},
'Outputs': {
'source_fps🟨': 'The frame rate of the video',
'source_frame_count🟨': 'How many total frames the video contains before accounting for frame rate or select_every_nth',
'source_duration🟨': 'The length of images just returned in seconds',
'source_width🟨': 'The original width',
'source_height🟨': 'The original height',
}
}],
"VHS_VideoInfoLoaded": ['Video Info Loaded 🎥🅥🅗🅢', short_desc('Splits information on a video into a numerous outputs describing the file itself after accounting for load options'),
{'Inputs': {
'video_info': 'A connection to a Load Video node',
},
'Outputs': {
'loaded_fps🟦': 'The frame rate after accounting for force_rate and select_every_nth. This output is of particular use as it can be connected to the converted frame_rate input of a Video Combine node to ensure audio remains synchronized.',
'loaded_frame_count🟦': 'The number of frames returned by the current execution. Identical to the frame_count returned by the node itself',
'loaded_duration🟦': 'The duration in seconds of returned images after accounting for frame_load_cap',
'loaded_width🟦': 'The width of the video after scaling. This is the dimension of the corresponding image even if loading as a latent directly',
'loaded_height🟦': 'The height of the video after scaling. This is the dimension of the corresponding image even if loading as a latent directly',
}
}],
"VHS_SelectFilename": ['VAE Select Filename 🎥🅥🅗🅢', short_desc('Select a single filename from the VHS_FILENAMES output by a Video Combine and return it as a string'),
'Take care when combining this node with Prune Outputs. The VHS_FILENAMES object is immutable and will always contain the full list of output files, but execution order is undefined behavior (currently, Prune Outputs will generally execute first) and SelectFilename may return a path to a file that no longer exists.',
{'Inputs': {
'filenames': 'A VHS_FILENAMES from a Video Combine node',
},
'Outputs': {
'filename': 'A string representation of the full output path for the chosen file',
},
'Widgets': {
'index': 'The index of which file should be selected. The default, -1, chooses the most complete output',
},
}],
# Batched Nodes
"VHS_VAEEncodeBatched": ['VAE Encode Batched 🎥🅥🅗🅢', short_desc('Encode images as latents with a manually specified batch size.'),
"Some people have ran into VRAM issues when encoding or decoding large batches of images. As a workaround, this node lets you manually set a batch size when encoding images.",
"Unless these issues have been encountered, it is simpler to use the native VAE Encode or to encode directly from a Load Video",
{'Inputs': {
'pixels': 'The images to be encoded.',
'vae': 'The VAE to use when encoding.',
},
'Outputs': {
'LATENT': 'The encoded latents.',
},
'Widgets': {
'per_batch': 'The maximum number of images to encode in each batch.',
},
}],
"VHS_VAEDecodeBatched": ['VAE Decode Batched 🎥🅥🅗🅢', short_desc('Decode latents to images with a manually specified batch size'),
"Some people have ran into VRAM issues when encoding or decoding large batches of images. As a workaround, this node lets you manually set a batch size when decoding latents.",
"Unless these issues have been encountered, it is simpler to use the native VAE Decode or to decode from a Video Combine directly",
{'Inputs': {
'samples': 'The latents to be decoded.',
'vae': 'The VAE to use when decoding.',
},
'Outputs': {
'IMAGE': 'The decoded images.',
},
'Widgets': {
'per_batch': 'The maximum number of images to decode in each batch.',
},
}],
# Latent and Image nodes
"VHS_SplitLatents": ['Split Latents 🎥🅥🅗🅢', short_desc('Split a set of latents into two groups'),
{'Inputs': {
'latents': 'The latents to be split.',
},
'Outputs': {
'LATENT_A': 'The first group of latents',
'A_count': 'The number of latents in group A. This will be equal to split_index unless the latents input has length less than split_index',
'LATENT_B': 'The second group of latents',
'B_count': 'The number of latents in group B'
},
'Widgets': {
'split_index': 'The index of the first latent that will be in the second output groups.',
},
}],
"VHS_SplitImages": ['Split Images 🎥🅥🅗🅢', short_desc('Split a set of images into two groups'),
{'Inputs': {
'images': 'The images to be split.',
},
'Outputs': {
'IMAGE_A': 'The first group of images',
'A_count': 'The number of images in group A. This will be equal to split_index unless the images input has length less than split_index',
'IMAGE_B': 'The second group of images',
'B_count': 'The number of images in group B'
},
'Widgets': {
'split_index': 'The index of the first latent that will be in the second output groups.',
},
}],
"VHS_SplitMasks": ['Split Masks 🎥🅥🅗🅢', short_desc('Split a set of masks into two groups'),
{'Inputs': {
'mask': 'The masks to be split.',
},
'Outputs': {
'MASK_A': 'The first group of masks',
'A_count': 'The number of masks in group A. This will be equal to split_index unless the mask input has length less than split_index',
'MASK_B': 'The second group of masks',
'B_count': 'The number of masks in group B'
},
'Widgets': {
'split_index': 'The index of the first latent that will be in the second output groups.',
},
}],
"VHS_MergeLatents": ['Merge Latents 🎥🅥🅗🅢', short_desc('Combine two groups of latents into a single group of latents'),
{'Inputs': {
'latents_A': 'The first group of latents',
'latents_B': 'The first group of latents',
},
'Outputs': {
'LATENT': 'The combined group of latents',
'count': 'The length of the combined group',
},
'Widgets': {
'merge_strategy': common_descriptions['merge_strategy'],
'scale_method': common_descriptions['scale_method'],
'crop': common_descriptions['crop_method'],
},
}],
"VHS_MergeImages": ['Merge Images 🎥🅥🅗🅢', short_desc('Combine two groups of images into a single group of images'),
{'Inputs': {
'images_A': 'The first group of images',
'images_B': 'The first group of images',
},
'Outputs': {
'IMAGE': 'The combined group of images',
'count': 'The length of the combined group',
},
'Widgets': {
'merge_strategy': common_descriptions['merge_strategy'],
'scale_method': common_descriptions['scale_method'],
'crop': common_descriptions['crop_method'],
},
}],
"VHS_MergeMasks": ['Merge Masks 🎥🅥🅗🅢', short_desc('Combine two groups of masks into a single group of masks'),
{'Inputs': {
'mask_A': 'The first group of masks',
'mask_B': 'The first group of masks',
},
'Outputs': {
'MASK': 'The combined group of masks',
'count': 'The length of the combined group',
},
'Widgets': {
'merge_strategy': common_descriptions['merge_strategy'],
'scale_method': common_descriptions['scale_method'],
'crop': common_descriptions['crop_method'],
},
}],
"VHS_GetLatentCount": format_type(common_descriptions['GetCount'], 'latent'),
"VHS_GetImageCount": format_type(common_descriptions['GetCount'], 'image'),
"VHS_GetMaskCount": format_type(common_descriptions['GetCount'], 'mask'),
"VHS_DuplicateLatents": ['Repeat Latents 🎥🅥🅗🅢', short_desc('Append copies of a latent to itself so it repeats'),
{'Inputs': {
'latents': 'The latents to be repeated',
},
'Outputs': {
'LATENT': 'The latent with repeats',
'count': 'The number of latents in the output. Equal to the length of the input latent * multiply_by',
},
'Widgets': {
'multiply_by': 'Controls the number of times the latent should repeat. 1, the default, means no change.',
},
}],
"VHS_DuplicateImages": ['Repeat Images 🎥🅥🅗🅢', short_desc('Append copies of a image to itself so it repeats'),
{'Inputs': {
'IMAGES': 'The image to be repeated',
},
'Outputs': {
'IMAGE': 'The image with repeats',
'count': 'The number of image in the output. Equal to the length of the input image * multiply_by',
},
'Widgets': {
'multiply_by': 'Controls the number of times the mask should repeat. 1, the default, means no change.',
},
}],
"VHS_DuplicateMasks": ['Repeat Masks 🎥🅥🅗🅢', short_desc('Append copies of a mask to itself so it repeats'),
{'Inputs': {
'masks': 'The masks to be repeated',
},
'Outputs': {
'LATENT': 'The mask with repeats',
'count': 'The number of mask in the output. Equal to the length of the input mask * multiply_by',
},
'Widgets': {
'multiply_by': 'Controls the number of times the mask should repeat. 1, the default, means no change.',
},
}],
"VHS_SelectEveryNthLatent": format_type(common_descriptions['SelectEveryNth'], 'latent'),
"VHS_SelectEveryNthImage": format_type(common_descriptions['SelectEveryNth'], 'image'),
#TODO: fix discrepency of input being mask instead of masks?
"VHS_SelectEveryNthMask": format_type(common_descriptions['SelectEveryNth'], 'mask', lowers='mask'),
#TODO: port documentation for select nodes to new system
#"VHS_SelectLatents": None,
#"VHS_SelectImages": None,
#"VHS_SelectMasks": None,
"VHS_Unbatch": ['Unbatch 🎥🅥🅗🅢', short_desc('Unbatch a list of items into a single concatenated item'),
"Useful for when you want a single video output from a complex workflow",
"Has no relation to the Meta Batch system of VHS",
{'Inputs': {
'batched': 'Any input which may or may not be batched',
},
'Outputs': {
'unbatched': 'A single output element. Torch tensors are concatenated across dim 0, all other types are added which functions as concatenation for strings and arrays, but may give undesired results for other types',
},
}],
"VHS_SelectLatest": ['Select Latest 🎥🅥🅗🅢', short_desc('Experimental virtual node to select the most recently modified file from a given folder'),
"Assists in the creation of workflows where outputs from one execution are used elsewhere in subsequent executions.",
{'Inputs': {
'filename_prefix': 'A path which can consist of a combination of folders and a prefix which candidate files must match',
'filename_postfix': 'A string which chich the selected file must end with. Useful for limiting to a target extension.',
},
'Outputs': {
'Filename': 'A string representing a file path to the most recently modified file.',
},
}],
}
def as_html(entry, depth=0):
if isinstance(entry, dict):
size = 0.8 if depth < 2 else 1
html = ''
for k in entry:
if k == "collapsed":
continue
collapse_single = k.endswith("_collapsed")
if collapse_single:
name = k[:-len("_collapsed")]
else:
name = k
collapse_flag = ' VHS_precollapse' if entry.get("collapsed", False) or collapse_single else ''
html += f'<div vhs_title=\"{name}\" style=\"display: flex; font-size: {size}em\" class=\"VHS_collapse{collapse_flag}\"><div style=\"color: #AAA; height: 1.5em;\">[<span style=\"font-family: monospace\">-</span>]</div><div style=\"width: 100%\">{name}: {as_html(entry[k], depth=depth+1)}</div></div>'
return html
if isinstance(entry, list):
if depth == 0:
depth += 1
size = .8
else:
size = 1
html = ''
html += entry[0]
for i in entry[1:]:
html += f'<div style=\"font-size: {size}em\">{as_html(i, depth=depth)}</div>'
return html
return str(entry)
def format_descriptions(nodes):
for k in descriptions:
if k.endswith("_collapsed"):
k = k[:-len("_collapsed")]
nodes[k].DESCRIPTION = as_html(descriptions[k])
undocumented_nodes = []
for k in nodes:
if not hasattr(nodes[k], "DESCRIPTION"):
undocumented_nodes.append(k)
if len(undocumented_nodes) > 0:
logger.warning('Some nodes have not been documented %s', undocumented_nodes)
+121 -29
View File
@@ -3,7 +3,7 @@ import torch
import comfy.utils
from .utils import BIGMIN, BIGMAX
from .utils import BIGMIN, BIGMAX, select_indexes_from_str, convert_str_to_indexes, select_indexes
class MergeStrategies:
@@ -48,13 +48,15 @@ class SplitLatents:
RETURN_NAMES = ("LATENT_A", "A_count", "LATENT_B", "B_count")
FUNCTION = "split_latents"
def split_latents(self, latents: dict, split_index: int):
latents = latents.copy()
group_a = latents["samples"][:split_index]
group_b = latents["samples"][split_index:]
group_a_latent = {"samples": group_a}
group_b_latent = {"samples": group_b}
return (group_a_latent, group_a.size(0), group_b_latent, group_b.size(0))
def split_latents(self, latents: dict[str, Tensor], split_index: int):
latents_len = len(latents["samples"])
group_a = latents.copy()
group_b = latents.copy()
for key, val in latents.items():
if type(val) == Tensor and len(val) == latents_len:
group_a[key] = latents[key][:split_index]
group_b[key] = latents[key][split_index:]
return (group_a, group_a["samples"].size(0), group_b, group_b["samples"].size(0))
class SplitImages:
@@ -125,6 +127,7 @@ class MergeLatents:
latents_A = latents_A.copy()["samples"]
latents_B = latents_B.copy()["samples"]
# TODO: handle other properties on latents besides just "samples"
# if not same dimensions, do scaling
if latents_A.shape[3] != latents_B.shape[3] or latents_A.shape[2] != latents_B.shape[2]:
A_size = latents_A.shape[3] * latents_A.shape[2]
@@ -271,9 +274,13 @@ class SelectEveryNthLatent:
RETURN_NAMES = ("LATENT", "count",)
FUNCTION = "select_latents"
def select_latents(self, latents: dict, select_every_nth: int, skip_first_latents: int):
sub_latents = latents.copy()["samples"][skip_first_latents::select_every_nth]
return ({"samples": sub_latents}, sub_latents.size(0))
def select_latents(self, latents: dict[str, Tensor], select_every_nth: int, skip_first_latents: int):
latents = latents.copy()
latents_len = len(latents["samples"])
for key, val in latents.items():
if type(val) == Tensor and len(val) == latents_len:
latents[key] = val[skip_first_latents::select_every_nth]
return (latents, latents["samples"].size(0))
class SelectEveryNthImage:
@@ -378,7 +385,7 @@ class GetMaskCount:
return (mask.size(0),)
class DuplicateLatents:
class RepeatLatents:
@classmethod
def INPUT_TYPES(s):
return {
@@ -395,15 +402,18 @@ class DuplicateLatents:
FUNCTION = "duplicate_input"
def duplicate_input(self, latents: dict[str, Tensor], multiply_by: int):
new_latents = latents.copy()
full_latents = []
for n in range(0, multiply_by):
full_latents.append(new_latents["samples"])
new_latents["samples"] = torch.cat(full_latents, dim=0)
return (new_latents, new_latents["samples"].size(0),)
latents = latents.copy()
latents_len = len(latents["samples"])
for key, val in latents.items():
if type(val) == Tensor and len(val) == latents_len:
full_latents = []
for _ in range(0, multiply_by):
full_latents.append(latents[key])
latents[key] = torch.cat(full_latents, dim=0)
return (latents, latents["samples"].size(0),)
class DuplicateImages:
class RepeatImages:
@classmethod
def INPUT_TYPES(s):
return {
@@ -427,7 +437,7 @@ class DuplicateImages:
return (new_images, new_images.size(0),)
class DuplicateMasks:
class RepeatMasks:
@classmethod
def INPUT_TYPES(s):
return {
@@ -451,12 +461,94 @@ class DuplicateMasks:
return (new_mask, new_mask.size(0),)
# class SelectLatents:
# @classmethod
# def INPUT_TYPES(s):
# return {
# "required": {
# "images": ("IMAGE",),
# "select_indeces": ("STRING", {"default": ""}),
# },
# }
select_description = """Use comma-separated indexes to select items in the given order.
Supports negative indexes, python-style ranges (end index excluded),
as well as range step.
Acceptable entries (assuming 16 items provided, so idxs 0 to 15 exist):
0 -> Returns [0]
-1 -> Returns [15]
0, 1, 13 -> Returns [0, 1, 13]
0:5, 13 -> Returns [0, 1, 2, 3, 4, 13]
0:-1 -> Returns [0, 1, 2, ..., 13, 14]
0:5:-1 -> Returns [4, 3, 2, 1, 0]
0:5:2 -> Returns [0, 2, 4]
::-1 -> Returns [15, 14, 13, ..., 2, 1, 0]
"""
class SelectLatents:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"latent": ("LATENT",),
"indexes": ("STRING", {"default": "0"}),
"err_if_missing": ("BOOLEAN", {"default": True}),
"err_if_empty": ("BOOLEAN", {"default": True}),
},
}
DESCRIPTION = select_description
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢/latent"
RETURN_TYPES = ("LATENT",)
FUNCTION = "select"
def select(self, latent: dict[str, Tensor], indexes: str, err_if_missing: bool, err_if_empty: bool):
# latents are a dict and may contain different stuff (like noise_mask), so need to account for it all
latent = latent.copy()
latents_len = len(latent["samples"])
real_idxs = convert_str_to_indexes(indexes, latents_len, allow_missing=not err_if_missing)
if err_if_empty and len(real_idxs) == 0:
raise Exception(f"Nothing was selected based on indexes found in '{indexes}'.")
for key, val in latent.items():
if type(val) == Tensor and len(val) == latents_len:
latent[key] = select_indexes(val, real_idxs)
return (latent,)
class SelectImages:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE",),
"indexes": ("STRING", {"default": "0"}),
"err_if_missing": ("BOOLEAN", {"default": True}),
"err_if_empty": ("BOOLEAN", {"default": True}),
},
}
DESCRIPTION = select_description
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢/image"
RETURN_TYPES = ("IMAGE",)
FUNCTION = "select"
def select(self, image: Tensor, indexes: str, err_if_missing: bool, err_if_empty: bool):
to_return = select_indexes_from_str(input_obj=image, indexes=indexes,
err_if_missing=err_if_missing, err_if_empty=err_if_empty)
to_return_type = type(to_return)
return (to_return,)
class SelectMasks:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"mask": ("MASK",),
"indexes": ("STRING", {"default": "0"}),
"err_if_missing": ("BOOLEAN", {"default": True}),
"err_if_empty": ("BOOLEAN", {"default": True}),
},
}
DESCRIPTION = select_description
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢/mask"
RETURN_TYPES = ("MASK",)
FUNCTION = "select"
def select(self, mask: Tensor, indexes: str, err_if_missing: bool, err_if_empty: bool):
return (select_indexes_from_str(input_obj=mask, indexes=indexes,
err_if_missing=err_if_missing, err_if_empty=err_if_empty),)
+114
View File
@@ -0,0 +1,114 @@
from PIL import Image
import time
import io
import struct
from threading import Thread
import torch.nn.functional as F
import torch
import latent_preview
import server
serv = server.PromptServer.instance
from .utils import hook
rates_table = {'Mochi': 24//6, 'LTXV': 24//8, 'HunyuanVideo': 24//4,
'Cosmos1CV8x8x8': 24//8, 'Wan21': 16//4, 'Wan22': 24//4}
class WrappedPreviewer(latent_preview.LatentPreviewer):
def __init__(self, previewer, rate=8):
self.first_preview = True
self.last_time = 0
self.c_index = 0
self.rate = rate
if hasattr(previewer, 'taesd'):
self.taesd = previewer.taesd
elif hasattr(previewer, 'latent_rgb_factors'):
self.latent_rgb_factors = previewer.latent_rgb_factors
self.latent_rgb_factors_bias = previewer.latent_rgb_factors_bias
self.latent_rgb_factors_reshape = getattr(previewer, 'latent_rgb_factors_reshape', None)
else:
raise Exception('Unsupported preview type for VHS animated previews')
def decode_latent_to_preview_image(self, preview_format, x0):
if x0.ndim == 5:
#Keep batch major
x0 = x0.movedim(2,1)
x0 = x0.reshape((-1,)+x0.shape[-3:])
num_images = x0.size(0)
new_time = time.time()
num_previews = int((new_time - self.last_time) * self.rate)
self.last_time = self.last_time + num_previews/self.rate
if num_previews > num_images:
num_previews = num_images
elif num_previews <= 0:
return None
if self.first_preview:
self.first_preview = False
serv.send_sync('VHS_latentpreview', {'length':num_images, 'rate': self.rate, 'id': serv.last_node_id})
self.last_time = new_time + 1/self.rate
if self.c_index + num_previews > num_images:
x0 = x0.roll(-self.c_index, 0)[:num_previews]
else:
x0 = x0[self.c_index:self.c_index + num_previews]
Thread(target=self.process_previews, args=(x0, self.c_index,
num_images)).run()
self.c_index = (self.c_index + num_previews) % num_images
return None
def process_previews(self, image_tensor, ind, leng):
image_tensor = self.decode_latent_to_preview(image_tensor)
if image_tensor.size(1) > 512 or image_tensor.size(2) > 512:
image_tensor = image_tensor.movedim(-1,0)
if image_tensor.size(2) < image_tensor.size(3):
height = (512 * image_tensor.size(2)) // image_tensor.size(3)
image_tensor = F.interpolate(image_tensor, (height,512), mode='bilinear')
else:
width = (512 * image_tensor.size(3)) // image_tensor.size(2)
image_tensor = F.interpolate(image_tensor, (512, width), mode='bilinear')
image_tensor = image_tensor.movedim(0,-1)
previews_ubyte = (((image_tensor + 1.0) / 2.0).clamp(0, 1) # change scale from -1..1 to 0..1
.mul(0xFF) # to 0..255
).to(device="cpu", dtype=torch.uint8)
for preview in previews_ubyte:
i = Image.fromarray(preview.numpy())
message = io.BytesIO()
message.write((1).to_bytes(length=4, byteorder='big')*2)
message.write(ind.to_bytes(length=4, byteorder='big'))
message.write(struct.pack('16p', serv.last_node_id.encode('ascii')))
i.save(message, format="JPEG", quality=95, compress_level=1)
#NOTE: send sync already uses call_soon_threadsafe
serv.send_sync(server.BinaryEventTypes.PREVIEW_IMAGE,
message.getvalue(), serv.client_id)
ind = (ind + 1) % leng
def decode_latent_to_preview(self, x0):
if hasattr(self, 'taesd'):
x_sample = self.taesd.decode(x0).movedim(1, 3)
return x_sample
else:
if self.latent_rgb_factors_reshape is not None:
x0 = self.latent_rgb_factors_reshape(x0)
self.latent_rgb_factors = self.latent_rgb_factors.to(dtype=x0.dtype, device=x0.device)
if self.latent_rgb_factors_bias is not None:
self.latent_rgb_factors_bias = self.latent_rgb_factors_bias.to(dtype=x0.dtype, device=x0.device)
latent_image = F.linear(x0.movedim(1, -1), self.latent_rgb_factors,
bias=self.latent_rgb_factors_bias)
return latent_image
@hook(latent_preview, 'get_previewer')
def get_latent_video_previewer(device, latent_format, *args, **kwargs):
node_id = serv.last_node_id
previewer = get_latent_video_previewer.__wrapped__(device, latent_format, *args, **kwargs)
try:
extra_info = next(serv.prompt_queue.currently_running.values().__iter__()) \
[3]['extra_pnginfo']['workflow']['extra']
prev_setting = extra_info.get('VHS_latentpreview', False)
if extra_info.get('VHS_latentpreviewrate', 0) != 0:
rate_setting = extra_info['VHS_latentpreviewrate']
else:
rate_setting = rates_table.get(latent_format.__class__.__name__, 8)
except:
#For safety since there's lots of keys, any of which can fail
prev_setting = False
if not prev_setting or not hasattr(previewer, "decode_latent_to_preview"):
return previewer
return WrappedPreviewer(previewer, rate_setting)
+4 -1
View File
@@ -55,6 +55,8 @@ def images_generator(directory: str, image_load_cap: int = 0, skip_first_images:
sizes[i.size] = count +1
size = max(sizes.items(), key=lambda x: x[1])[0]
yield size[0], size[1], has_alpha
if meta_batch is not None:
yield min(image_load_cap, len(dir_files)) or len(dir_files)
iformat = "RGBA" if has_alpha else "RGB"
def load_image(file_path):
@@ -99,6 +101,7 @@ def load_images(directory: str, image_load_cap: int = 0, skip_first_images: int
(width, height, has_alpha) = next(gen)
if meta_batch is not None:
meta_batch.inputs[unique_id] = (gen, width, height, has_alpha)
meta_batch.total_frames = min(meta_batch.total_frames, next(gen))
else:
gen, width, height, has_alpha = meta_batch.inputs[unique_id]
@@ -164,7 +167,7 @@ class LoadImagesFromDirectoryPath:
def INPUT_TYPES(s):
return {
"required": {
"directory": ("STRING", {"default": "X://path/to/images", "vhs_path_extensions": []}),
"directory": ("STRING", {"placeholder": "X://path/to/images", "vhs_path_extensions": []}),
},
"optional": {
"image_load_cap": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1}),
+431 -70
View File
@@ -5,28 +5,66 @@ import torch
from PIL import Image, ImageOps
import cv2
import psutil
import subprocess
import re
import time
import folder_paths
from comfy.utils import common_upscale, ProgressBar
import nodes
from comfy.k_diffusion.utils import FolderOfImages
from .logger import logger
from .utils import BIGMAX, DIMMAX, calculate_file_hash, get_sorted_dir_files_from_directory, lazy_get_audio, hash_path, validate_path, strip_path
from .utils import BIGMAX, DIMMAX, calculate_file_hash, get_sorted_dir_files_from_directory,\
lazy_get_audio, hash_path, validate_path, strip_path, try_download_video, \
is_url, imageOrLatent, ffmpeg_path, ENCODE_ARGS, floatOrInt
video_extensions = ['webm', 'mp4', 'mkv', 'gif']
video_extensions = ['webm', 'mp4', 'mkv', 'gif', 'mov']
VHSLoadFormats = {
'None': {},
'AnimateDiff': {'target_rate': 8, 'dim': (8,0,512,512)},
'Mochi': {'target_rate': 24, 'dim': (16,0,848,480), 'frames':(6,1)},
'LTXV': {'target_rate': 24, 'dim': (32,0,768,512), 'frames':(8,1)},
'Hunyuan': {'target_rate': 24, 'dim': (16,0,848,480), 'frames':(4,1)},
'Cosmos': {'target_rate': 24, 'dim': (16,0,1280,704), 'frames':(8,1)},
'Wan': {'target_rate': 16, 'dim': (8,0,832,480), 'frames':(4,1)},
}
"""
External plugins may add additional formats to nodes.VHSLoadFormats
In addition to shorthand options, direct widget names will map a given dict to options.
Adding a third arguement to a frames tuple can enable strict checks on number
of loaded frames, i.e (8,1,True)
"""
if not hasattr(nodes, 'VHSLoadFormats'):
nodes.VHSLoadFormats = {}
def get_load_formats():
#TODO: check if {**extra_config.VHSLoafFormats, **VHSLoadFormats} has minimum version
formats = {}
formats.update(nodes.VHSLoadFormats)
formats.update(VHSLoadFormats)
return (list(formats.keys()),
{'default': 'AnimateDiff', 'formats': formats})
def get_format(format):
if format in VHSLoadFormats:
return VHSLoadFormats[format]
return nodes.VHSLoadFormats.get(format, {})
def is_gif(filename) -> bool:
file_parts = filename.split('.')
return len(file_parts) > 1 and file_parts[-1] == "gif"
def target_size(width, height, force_size, custom_width, custom_height, downscale_ratio=8) -> tuple[int, int]:
if force_size == "Disabled":
def target_size(width, height, custom_width, custom_height, downscale_ratio=8) -> tuple[int, int]:
if downscale_ratio is None:
downscale_ratio = 8
if custom_width == 0 and custom_height == 0:
pass
elif force_size == "Custom Width" or force_size.endswith('x?'):
elif custom_height == 0:
height *= custom_width/width
width = custom_width
elif force_size == "Custom Height" or force_size.startswith('?x'):
elif custom_width == 0:
width *= custom_height/height
height = custom_height
else:
@@ -38,10 +76,9 @@ def target_size(width, height, force_size, custom_width, custom_height, downscal
def cv_frame_generator(video, force_rate, frame_load_cap, skip_first_frames,
select_every_nth, meta_batch=None, unique_id=None):
video_cap = cv2.VideoCapture(strip_path(video))
if not video_cap.isOpened():
video_cap = cv2.VideoCapture(video)
if not video_cap.isOpened() or not video_cap.grab():
raise ValueError(f"{video} could not be loaded with cv.")
pbar = ProgressBar(frame_load_cap) if frame_load_cap > 0 else None
# extract video metadata
fps = video_cap.get(cv2.CAP_PROP_FPS)
@@ -50,6 +87,12 @@ def cv_frame_generator(video, force_rate, frame_load_cap, skip_first_frames,
total_frames = int(video_cap.get(cv2.CAP_PROP_FRAME_COUNT))
duration = total_frames / fps
width = 0
if width <=0 or height <=0:
_, frame = video_cap.retrieve()
height, width, _ = frame.shape
# set video_cap to look at start_index frame
total_frame_count = 0
total_frames_evaluated = -1
@@ -62,9 +105,20 @@ def cv_frame_generator(video, force_rate, frame_load_cap, skip_first_frames,
else:
target_frame_time = 1/force_rate
yield (width, height, fps, duration, total_frames, target_frame_time)
time_offset=target_frame_time - base_frame_time
if total_frames > 0:
if force_rate != 0:
yieldable_frames = int(total_frames / fps * force_rate)
else:
yieldable_frames = total_frames
if select_every_nth:
yieldable_frames //= select_every_nth
if frame_load_cap != 0:
yieldable_frames = min(frame_load_cap, yieldable_frames)
else:
yieldable_frames = 0
yield (width, height, fps, duration, total_frames, target_frame_time, yieldable_frames)
pbar = ProgressBar(yieldable_frames)
time_offset=target_frame_time
while video_cap.isOpened():
if time_offset < target_frame_time:
is_returned = video_cap.grab()
@@ -103,7 +157,7 @@ def cv_frame_generator(video, force_rate, frame_load_cap, skip_first_frames,
prev_frame = frame
frames_added += 1
if pbar is not None:
pbar.update_absolute(frames_added, frame_load_cap)
pbar.update_absolute(frames_added, yieldable_frames)
# if cap exists and we've reached it, stop processing frames
if frame_load_cap > 0 and frames_added >= frame_load_cap:
break
@@ -113,6 +167,121 @@ def cv_frame_generator(video, force_rate, frame_load_cap, skip_first_frames,
if prev_frame is not None:
yield prev_frame
def ffmpeg_frame_generator(video, force_rate, frame_load_cap, start_time,
custom_width, custom_height, downscale_ratio=8,
meta_batch=None, unique_id=None):
args_input = ["-i", video]
args_dummy = [ffmpeg_path] + args_input +['-c', 'copy', '-frames:v', '1', "-f", "null", "-"]
size_base = None
fps_base = None
try:
dummy_res = subprocess.run(args_dummy, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, check=True)
except subprocess.CalledProcessError as e:
raise Exception("An error occurred in the ffmpeg subprocess:\n" \
+ e.stderr.decode(*ENCODE_ARGS))
lines = dummy_res.stderr.decode(*ENCODE_ARGS)
if "Video: vp9 " in lines:
args_input = ["-c:v", "libvpx-vp9"] + args_input
args_dummy = [ffmpeg_path] + args_input +['-c', 'copy', '-frames:v', '1', "-f", "null", "-"]
try:
dummy_res = subprocess.run(args_dummy, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, check=True)
except subprocess.CalledProcessError as e:
raise Exception("An error occurred in the ffmpeg subprocess:\n" \
+ e.stderr.decode(*ENCODE_ARGS))
lines = dummy_res.stderr.decode(*ENCODE_ARGS)
for line in lines.split('\n'):
match = re.search("^ *Stream .* Video.*, ([1-9]|\\d{2,})x(\\d+)", line)
if match is not None:
size_base = [int(match.group(1)), int(match.group(2))]
fps_match = re.search(", ([\\d\\.]+) fps", line)
if fps_match:
fps_base = float(fps_match.group(1))
else:
fps_base = 1
alpha = re.search("(yuva|rgba|bgra|gbra)", line) is not None
break
else:
raise Exception("Failed to parse video/image information. FFMPEG output:\n" + lines)
durs_match = re.search("Duration: (\\d+:\\d+:\\d+\\.\\d+),", lines)
if durs_match:
durs = durs_match.group(1).split(':')
duration = int(durs[0])*3600 + int(durs[1])*60 + float(durs[2])
else:
duration = 0
if start_time > 0:
if start_time > 4:
post_seek = ['-ss', '4']
args_input = ['-ss', str(start_time - 4)] + args_input
else:
post_seek = ['-ss', str(start_time)]
else:
post_seek = []
args_all_frames = [ffmpeg_path, "-v", "error", "-an"] + \
args_input + ["-pix_fmt", "rgba64le"] + post_seek
vfilters = []
if force_rate != 0:
vfilters.append("fps=fps="+str(force_rate))
if custom_width != 0 or custom_height != 0:
size = target_size(size_base[0], size_base[1], custom_width,
custom_height, downscale_ratio=downscale_ratio)
ar = float(size[0])/float(size[1])
if abs(size_base[0]*ar-size_base[1]) >= 1:
#Aspect ratio is changed. Crop to new aspect ratio before scale
vfilters.append(f"crop=if(gt({ar}\\,a)\\,iw\\,ih*{ar}):if(gt({ar}\\,a)\\,iw/{ar}\\,ih)")
size_arg = ':'.join(map(str,size))
vfilters.append(f"scale={size_arg}")
else:
size = size_base
if len(vfilters) > 0:
args_all_frames += ["-vf", ",".join(vfilters)]
yieldable_frames = (force_rate or fps_base)*duration
if frame_load_cap > 0:
args_all_frames += ["-frames:v", str(frame_load_cap)]
yieldable_frames = min(yieldable_frames, frame_load_cap)
yield (size_base[0], size_base[1], fps_base, duration, fps_base * duration,
1/(force_rate or fps_base), yieldable_frames, size[0], size[1], alpha)
args_all_frames += ["-f", "rawvideo", "-"]
pbar = ProgressBar(yieldable_frames)
try:
with subprocess.Popen(args_all_frames, stdout=subprocess.PIPE) as proc:
#Manually buffer enough bytes for an image
bpi = size[0] * size[1] * 8
current_bytes = bytearray(bpi)
current_offset=0
prev_frame = None
while True:
bytes_read = proc.stdout.read(bpi - current_offset)
if bytes_read is None:#sleep to wait for more data
time.sleep(.1)
continue
if len(bytes_read) == 0:#EOF
break
current_bytes[current_offset:len(bytes_read)] = bytes_read
current_offset+=len(bytes_read)
if current_offset == bpi:
if prev_frame is not None:
yield prev_frame
pbar.update(1)
prev_frame = np.frombuffer(current_bytes, dtype=np.dtype(np.uint16).newbyteorder("<")).reshape(size[1], size[0], 4) / (2**16-1)
if not alpha:
prev_frame = prev_frame[:, :, :-1]
current_offset = 0
except BrokenPipeError as e:
raise Exception("An error occured in the ffmpeg subprocess:\n" \
+ proc.stderr.read().decode(*ENCODE_ARGS))
if meta_batch is not None:
meta_batch.inputs.pop(unique_id)
meta_batch.has_closed_inputs = True
if prev_frame is not None:
yield prev_frame
#Python 3.12 adds an itertools.batched, but it's easily replicated for legacy support
def batched(it, n):
while batch := tuple(itertools.islice(it, n)):
@@ -121,21 +290,50 @@ def batched_vae_encode(images, vae, frames_per_batch):
for batch in batched(images, frames_per_batch):
image_batch = torch.from_numpy(np.array(batch))
yield from vae.encode(image_batch).numpy()
def resized_cv_frame_gen(custom_width, custom_height, downscale_ratio, **kwargs):
gen = cv_frame_generator(**kwargs)
info = next(gen)
width, height = info[0], info[1]
frames_per_batch = (1920 * 1080 * 16) // (width * height) or 1
if kwargs.get('meta_batch', None) is not None:
frames_per_batch = min(frames_per_batch, kwargs['meta_batch'].frames_per_batch)
if custom_width != 0 or custom_height != 0 or downscale_ratio is not None:
new_size = target_size(width, height, custom_width, custom_height, downscale_ratio)
yield (*info, new_size[0], new_size[1], False)
if new_size[0] != width or new_size[1] != height:
def rescale(frame):
s = torch.from_numpy(np.fromiter(frame, np.dtype((np.float32, (height, width, 3)))))
s = s.movedim(-1,1)
s = common_upscale(s, new_size[0], new_size[1], "lanczos", "center")
return s.movedim(1,-1).numpy()
yield from itertools.chain.from_iterable(map(rescale, batched(gen, frames_per_batch)))
return
else:
yield (*info, info[0], info[1], False)
yield from gen
def load_video_cv(video: str, force_rate: int, force_size: str,
custom_width: int,custom_height: int, frame_load_cap: int,
skip_first_frames: int, select_every_nth: int,
meta_batch=None, unique_id=None, memory_limit_mb=None, vae=None):
def load_video(meta_batch=None, unique_id=None, memory_limit_mb=None, vae=None,
generator=resized_cv_frame_gen, format='None', **kwargs):
if 'force_size' in kwargs:
kwargs.pop('force_size')
logger.warn("force_size has been removed. Did you reload the webpage after updating?")
format = get_format(format)
kwargs['video'] = strip_path(kwargs['video'])
if vae is not None:
downscale_ratio = getattr(vae, "downscale_ratio", 8)
else:
downscale_ratio = format.get('dim', (1,))[0]
if meta_batch is None or unique_id not in meta_batch.inputs:
gen = cv_frame_generator(video, force_rate, frame_load_cap, skip_first_frames,
select_every_nth, meta_batch, unique_id)
(width, height, fps, duration, total_frames, target_frame_time) = next(gen)
gen = generator(meta_batch=meta_batch, unique_id=unique_id, downscale_ratio=downscale_ratio, **kwargs)
(width, height, fps, duration, total_frames, target_frame_time, yieldable_frames, new_width, new_height, alpha) = next(gen)
if meta_batch is not None:
meta_batch.inputs[unique_id] = (gen, width, height, fps, duration, total_frames, target_frame_time)
meta_batch.inputs[unique_id] = (gen, width, height, fps, duration, total_frames, target_frame_time, yieldable_frames, new_width, new_height, alpha)
if yieldable_frames:
meta_batch.total_frames = min(meta_batch.total_frames, yieldable_frames)
else:
(gen, width, height, fps, duration, total_frames, target_frame_time) = meta_batch.inputs[unique_id]
(gen, width, height, fps, duration, total_frames, target_frame_time, yieldable_frames, new_width, new_height, alpha) = meta_batch.inputs[unique_id]
memory_limit = None
if memory_limit_mb is not None:
@@ -147,40 +345,37 @@ def load_video_cv(video: str, force_rate: int, force_size: str,
memory_limit = (psutil.virtual_memory().available + psutil.swap_memory().free) - 2 ** 27
except:
logger.warn("Failed to calculate available memory. Memory load limit has been disabled")
#space required to load as f32, exist as latent with wiggle room, decode to f32
#TODO: fix when vae is not None
memory_limit = BIGMAX
if vae is not None:
#space required to load as f32, exist as latent with wiggle room, decode to f32
max_loadable_frames = int(memory_limit//(width*height*3*(4+4+1/10)))
else:
#TODO: use better estimate for when vae is not None
#Consider completely ignoring for load_latent case?
max_loadable_frames = int(memory_limit//(width*height*3*(.1)))
if meta_batch is not None:
if 'frames' in format:
if meta_batch.frames_per_batch % format['frames'][0] != format['frames'][1]:
error = (meta_batch.frames_per_batch - format['frames'][1]) % format['frames'][0]
suggested = meta_batch.frames_per_batch - error
if error > format['frames'][0] / 2:
suggested += format['frames'][0]
raise RuntimeError(f"The chosen frames per batch is incompatible with the selected format. Try {suggested}")
if meta_batch.frames_per_batch > max_loadable_frames:
raise RuntimeError(f"Meta Batch set to {meta_batch.frames_per_batch} frames but only {max_loadable_frames} can fit in memory")
gen = itertools.islice(gen, meta_batch.frames_per_batch)
else:
original_gen = gen
gen = itertools.islice(gen, max_loadable_frames)
downscale_ratio = getattr(vae, "downscale_ratio", 8)
frames_per_batch = (1920 * 1080 * 16) // (width * height) or 1
if force_size != "Disabled" or vae is not None:
new_size = target_size(width, height, force_size, custom_width, custom_height, downscale_ratio)
if new_size[0] != width or new_size[1] != height:
def rescale(frame):
s = torch.from_numpy(np.fromiter(frame, np.dtype((np.float32, (height, width, 3)))))
s = s.movedim(-1,1)
s = common_upscale(s, new_size[0], new_size[1], "lanczos", "center")
return s.movedim(1,-1).numpy()
gen = itertools.chain.from_iterable(map(rescale, batched(gen, frames_per_batch)))
else:
new_size = width, height
if vae is not None:
gen = batched_vae_encode(gen, vae, frames_per_batch)
vw,vh = new_size[0]//downscale_ratio, new_size[1]//downscale_ratio
images = torch.from_numpy(np.fromiter(gen, np.dtype((np.float32, (4,vh,vw)))))
vw,vh = new_width//downscale_ratio, new_height//downscale_ratio
channels = getattr(vae, 'latent_channels', 4)
images = torch.from_numpy(np.fromiter(gen, np.dtype((np.float32, (channels,vh,vw)))))
else:
#Some minor wizardry to eliminate a copy and reduce max memory by a factor of ~2
images = torch.from_numpy(np.fromiter(gen, np.dtype((np.float32, (new_size[1], new_size[0], 3)))))
images = torch.from_numpy(np.fromiter(gen, np.dtype((np.float32, (new_height, new_width, 4 if alpha else 3)))))
if meta_batch is None and memory_limit is not None:
try:
next(original_gen)
@@ -189,12 +384,23 @@ def load_video_cv(video: str, force_rate: int, force_size: str,
pass
if len(images) == 0:
raise RuntimeError("No frames generated")
if 'frames' in format and len(images) % format['frames'][0] != format['frames'][1]:
err_msg = f"The number of frames loaded {len(images)}, does not match the requirements of the currently selected format."
if len(format['frames']) > 2 and format['frames'][2]:
raise RuntimeError(err_msg)
div, mod = format['frames'][:2]
frames = (len(images) - mod) // div * div + mod
images = images[:frames]
#Commenting out log message since it's displayed in UI. consider further
#logger.warn(err_msg + f" Output has been truncated to {len(images)} frames.")
if 'start_time' in kwargs:
start_time = kwargs['start_time']
else:
start_time = kwargs['skip_first_frames'] * target_frame_time
target_frame_time *= kwargs.get('select_every_nth', 1)
#Setup lambda for lazy audio capture
audio = lazy_get_audio(video, skip_first_frames * target_frame_time,
frame_load_cap*target_frame_time*select_every_nth)
audio = lazy_get_audio(kwargs['video'], start_time, kwargs['frame_load_cap']*target_frame_time)
#Adjust target_frame_time for select_every_nth
target_frame_time *= select_every_nth
video_info = {
"source_fps": fps,
"source_frame_count": total_frames,
@@ -204,13 +410,13 @@ def load_video_cv(video: str, force_rate: int, force_size: str,
"loaded_fps": 1/target_frame_time,
"loaded_frame_count": len(images),
"loaded_duration": len(images) * target_frame_time,
"loaded_width": new_size[0],
"loaded_height": new_size[1],
"loaded_width": new_width,
"loaded_height": new_height,
}
if vae is None:
return (images, len(images), audio, video_info, None)
return (images, len(images), audio, video_info)
else:
return (None, len(images), audio, video_info, {"samples": images})
return ({"samples": images}, len(images), audio, video_info)
@@ -222,37 +428,38 @@ class LoadVideoUpload:
for f in os.listdir(input_dir):
if os.path.isfile(os.path.join(input_dir, f)):
file_parts = f.split('.')
if len(file_parts) > 1 and (file_parts[-1] in video_extensions):
if len(file_parts) > 1 and (file_parts[-1].lower() in video_extensions):
files.append(f)
return {"required": {
"video": (sorted(files),),
"force_rate": ("INT", {"default": 0, "min": 0, "max": 60, "step": 1}),
"force_size": (["Disabled", "Custom Height", "Custom Width", "Custom", "256x?", "?x256", "256x256", "512x?", "?x512", "512x512"],),
"custom_width": ("INT", {"default": 512, "min": 0, "max": DIMMAX, "step": 8}),
"custom_height": ("INT", {"default": 512, "min": 0, "max": DIMMAX, "step": 8}),
"frame_load_cap": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1}),
"skip_first_frames": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1}),
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": BIGMAX, "step": 1}),
},
"force_rate": (floatOrInt, {"default": 0, "min": 0, "max": 60, "step": 1, "disable": 0}),
"custom_width": ("INT", {"default": 0, "min": 0, "max": DIMMAX, 'disable': 0}),
"custom_height": ("INT", {"default": 0, "min": 0, "max": DIMMAX, 'disable': 0}),
"frame_load_cap": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1, "disable": 0}),
"skip_first_frames": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1}),
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": BIGMAX, "step": 1}),
},
"optional": {
"meta_batch": ("VHS_BatchManager",),
"vae": ("VAE",),
"format": get_load_formats(),
},
"hidden": {
"force_size": "STRING",
"unique_id": "UNIQUE_ID"
},
}
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢"
RETURN_TYPES = ("IMAGE", "INT", "AUDIO", "VHS_VIDEOINFO", "LATENT")
RETURN_NAMES = ("IMAGE", "frame_count", "audio", "video_info", "LATENT")
RETURN_TYPES = (imageOrLatent, "INT", "AUDIO", "VHS_VIDEOINFO")
RETURN_NAMES = ("IMAGE", "frame_count", "audio", "video_info")
FUNCTION = "load_video"
def load_video(self, **kwargs):
kwargs['video'] = folder_paths.get_annotated_filepath(strip_path(kwargs['video']))
return load_video_cv(**kwargs)
return load_video(**kwargs)
@classmethod
def IS_CHANGED(s, video, **kwargs):
@@ -260,7 +467,7 @@ class LoadVideoUpload:
return calculate_file_hash(image_path)
@classmethod
def VALIDATE_INPUTS(s, video, force_size, **kwargs):
def VALIDATE_INPUTS(s, video):
if not folder_paths.exists_annotated_filepath(video):
return "Invalid video file: {}".format(video)
return True
@@ -271,40 +478,194 @@ class LoadVideoPath:
def INPUT_TYPES(s):
return {
"required": {
"video": ("STRING", {"default": "X://insert/path/here.mp4", "vhs_path_extensions": video_extensions}),
"force_rate": ("INT", {"default": 0, "min": 0, "max": 60, "step": 1}),
"force_size": (["Disabled", "Custom Height", "Custom Width", "Custom", "256x?", "?x256", "256x256", "512x?", "?x512", "512x512"],),
"custom_width": ("INT", {"default": 512, "min": 0, "max": DIMMAX, "step": 8}),
"custom_height": ("INT", {"default": 512, "min": 0, "max": DIMMAX, "step": 8}),
"frame_load_cap": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1}),
"video": ("STRING", {"placeholder": "X://insert/path/here.mp4", "vhs_path_extensions": video_extensions}),
"force_rate": (floatOrInt, {"default": 0, "min": 0, "max": 60, "step": 1, "disable": 0}),
"custom_width": ("INT", {"default": 0, "min": 0, "max": DIMMAX, 'disable': 0}),
"custom_height": ("INT", {"default": 0, "min": 0, "max": DIMMAX, 'disable': 0}),
"frame_load_cap": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1, "disable": 0}),
"skip_first_frames": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1}),
"select_every_nth": ("INT", {"default": 1, "min": 1, "max": BIGMAX, "step": 1}),
},
"optional": {
"meta_batch": ("VHS_BatchManager",),
"vae": ("VAE",),
"format": get_load_formats(),
},
"hidden": {
"force_size": "STRING",
"unique_id": "UNIQUE_ID"
},
}
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢"
RETURN_TYPES = ("IMAGE", "INT", "AUDIO", "VHS_VIDEOINFO", "LATENT")
RETURN_NAMES = ("IMAGE", "frame_count", "audio", "video_info", "LATENT")
RETURN_TYPES = (imageOrLatent, "INT", "AUDIO", "VHS_VIDEOINFO")
RETURN_NAMES = ("IMAGE", "frame_count", "audio", "video_info")
FUNCTION = "load_video"
def load_video(self, **kwargs):
if kwargs['video'] is None or validate_path(kwargs['video']) != True:
raise Exception("video is not a valid path: " + kwargs['video'])
return load_video_cv(**kwargs)
if is_url(kwargs['video']):
kwargs['video'] = try_download_video(kwargs['video']) or kwargs['video']
return load_video(**kwargs)
@classmethod
def IS_CHANGED(s, video, **kwargs):
return hash_path(video)
@classmethod
def VALIDATE_INPUTS(s, video, **kwargs):
def VALIDATE_INPUTS(s, video):
return validate_path(video, allow_none=True)
class LoadVideoFFmpegUpload:
@classmethod
def INPUT_TYPES(s):
input_dir = folder_paths.get_input_directory()
files = []
for f in os.listdir(input_dir):
if os.path.isfile(os.path.join(input_dir, f)):
file_parts = f.split('.')
if len(file_parts) > 1 and (file_parts[-1].lower() in video_extensions):
files.append(f)
return {"required": {
"video": (sorted(files),),
"force_rate": (floatOrInt, {"default": 0, "min": 0, "max": 60, "step": 1, "disable": 0}),
"custom_width": ("INT", {"default": 0, "min": 0, "max": DIMMAX, 'disable': 0}),
"custom_height": ("INT", {"default": 0, "min": 0, "max": DIMMAX, 'disable': 0}),
"frame_load_cap": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1, "disable": 0}),
"start_time": ("FLOAT", {"default": 0, "min": 0, "max": BIGMAX, "step": .001, "widgetType": "VHSTIMESTAMP"}),
},
"optional": {
"meta_batch": ("VHS_BatchManager",),
"vae": ("VAE",),
"format": get_load_formats(),
},
"hidden": {
"force_size": "STRING",
"unique_id": "UNIQUE_ID"
},
}
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢"
RETURN_TYPES = (imageOrLatent, "MASK", "AUDIO", "VHS_VIDEOINFO")
RETURN_NAMES = ("IMAGE", "mask", "audio", "video_info")
FUNCTION = "load_video"
def load_video(self, **kwargs):
kwargs['video'] = folder_paths.get_annotated_filepath(strip_path(kwargs['video']))
image, _, audio, video_info = load_video(**kwargs, generator=ffmpeg_frame_generator)
if image.size(3) == 4:
return (image[:,:,:,:3], 1-image[:,:,:,3], audio, video_info)
return (image, torch.zeros(image.size(0), 64, 64, device="cpu"), audio, video_info)
@classmethod
def IS_CHANGED(s, video, **kwargs):
image_path = folder_paths.get_annotated_filepath(video)
return calculate_file_hash(image_path)
@classmethod
def VALIDATE_INPUTS(s, video):
if not folder_paths.exists_annotated_filepath(video):
return "Invalid video file: {}".format(video)
return True
class LoadVideoFFmpegPath:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"video": ("STRING", {"placeholder": "X://insert/path/here.mp4", "vhs_path_extensions": video_extensions}),
"force_rate": (floatOrInt, {"default": 0, "min": 0, "max": 60, "step": 1, "disable": 0}),
"custom_width": ("INT", {"default": 0, "min": 0, "max": DIMMAX, 'disable': 0}),
"custom_height": ("INT", {"default": 0, "min": 0, "max": DIMMAX, 'disable': 0}),
"frame_load_cap": ("INT", {"default": 0, "min": 0, "max": BIGMAX, "step": 1, "disable": 0}),
"start_time": ("FLOAT", {"default": 0, "min": 0, "max": BIGMAX, "step": .001, "widgetType": "VHSTIMESTAMP"}),
},
"optional": {
"meta_batch": ("VHS_BatchManager",),
"vae": ("VAE",),
"format": get_load_formats(),
},
"hidden": {
"force_size": "STRING",
"unique_id": "UNIQUE_ID"
},
}
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢"
RETURN_TYPES = (imageOrLatent, "MASK", "AUDIO", "VHS_VIDEOINFO")
RETURN_NAMES = ("IMAGE", "mask", "audio", "video_info")
FUNCTION = "load_video"
def load_video(self, **kwargs):
if kwargs['video'] is None or validate_path(kwargs['video']) != True:
raise Exception("video is not a valid path: " + kwargs['video'])
if is_url(kwargs['video']):
kwargs['video'] = try_download_video(kwargs['video']) or kwargs['video']
image, _, audio, video_info = load_video(**kwargs, generator=ffmpeg_frame_generator)
if isinstance(image, dict):
return (image, None, audio, video_info)
if image.size(3) == 4:
return (image[:,:,:,:3], 1-image[:,:,:,3], audio, video_info)
return (image, torch.zeros(image.size(0), 64, 64, device="cpu"), audio, video_info)
@classmethod
def IS_CHANGED(s, video, **kwargs):
return hash_path(video)
@classmethod
def VALIDATE_INPUTS(s, video):
return validate_path(video, allow_none=True)
class LoadImagePath:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("STRING", {"placeholder": "X://insert/path/here.png", "vhs_path_extensions": list(FolderOfImages.IMG_EXTENSIONS)}),
"custom_width": ("INT", {"default": 0, "min": 0, "max": DIMMAX, "step": 8, 'disable': 0}),
"custom_height": ("INT", {"default": 0, "min": 0, "max": DIMMAX, "step": 8, 'disable': 0}),
},
"optional": {
"vae": ("VAE",),
},
"hidden": {
"force_size": "STRING",
},
}
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢"
RETURN_TYPES = (imageOrLatent, "MASK")
RETURN_NAMES = ("IMAGE", "mask")
FUNCTION = "load_image"
def load_image(self, **kwargs):
if kwargs['image'] is None or validate_path(kwargs['image']) != True:
raise Exception("image is not a valid path: " + kwargs['image'])
kwargs.update({'video': kwargs['image'], 'force_rate': 0, 'frame_load_cap': 0,
'start_time': 0})
kwargs.pop('image')
image, _, _, _ = load_video(**kwargs, generator=ffmpeg_frame_generator)
if isinstance(image, dict):
return (image, None)
if image.size(3) == 4:
return (image[:,:,:,:3], 1-image[:,:,:,3])
return (image, torch.zeros(image.size(0), 64, 64, device="cpu"))
@classmethod
def IS_CHANGED(s, image, **kwargs):
return hash_path(image)
@classmethod
def VALIDATE_INPUTS(s, image):
return validate_path(image, allow_none=True)
+374 -187
View File
@@ -12,88 +12,117 @@ from PIL.PngImagePlugin import PngInfo
from pathlib import Path
from string import Template
import itertools
import functools
import folder_paths
from .logger import logger
from .image_latent_nodes import *
from .load_video_nodes import LoadVideoUpload, LoadVideoPath
from .load_video_nodes import LoadVideoUpload, LoadVideoPath, LoadVideoFFmpegUpload, LoadVideoFFmpegPath, LoadImagePath
from .load_images_nodes import LoadImagesFromDirectoryUpload, LoadImagesFromDirectoryPath
from .batched_nodes import VAEEncodeBatched, VAEDecodeBatched
from .utils import ffmpeg_path, get_audio, hash_path, validate_path, requeue_workflow, gifski_path, calculate_file_hash, strip_path
from .utils import ffmpeg_path, get_audio, hash_path, validate_path, requeue_workflow, \
gifski_path, calculate_file_hash, strip_path, try_download_video, is_url, \
imageOrLatent, BIGMAX, merge_filter_args, ENCODE_ARGS, floatOrInt, cached, \
ContainsAll
from comfy.utils import ProgressBar
folder_paths.folder_names_and_paths["VHS_video_formats"] = (
[
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "video_formats"),
],
[".json"]
)
if 'VHS_video_formats' not in folder_paths.folder_names_and_paths:
folder_paths.folder_names_and_paths["VHS_video_formats"] = ((),{".json"})
if len(folder_paths.folder_names_and_paths['VHS_video_formats'][1]) == 0:
folder_paths.folder_names_and_paths["VHS_video_formats"][1].add(".json")
audio_extensions = ['mp3', 'mp4', 'wav', 'ogg']
def gen_format_widgets(video_format):
for k in video_format:
if k.endswith("_pass"):
for i in range(len(video_format[k])):
if isinstance(video_format[k][i], list):
item = [video_format[k][i]]
yield item
video_format[k][i] = item[0]
def flatten_list(l):
ret = []
for e in l:
if isinstance(e, list):
ret.extend(e)
else:
if isinstance(video_format[k], list):
item = [video_format[k]]
yield item
video_format[k] = item[0]
ret.append(e)
return ret
def iterate_format(video_format, for_widgets=True):
"""Provides an iterator over widgets, or arguments"""
def indirector(cont, index):
if isinstance(cont[index], list) and (not for_widgets
or len(cont[index])> 1 and not isinstance(cont[index][1], dict)):
inp = yield cont[index]
if inp is not None:
cont[index] = inp
yield
for k in video_format:
if k == "extra_widgets":
if for_widgets:
yield from video_format["extra_widgets"]
elif k.endswith("_pass"):
for i in range(len(video_format[k])):
yield from indirector(video_format[k], i)
if not for_widgets:
video_format[k] = flatten_list(video_format[k])
else:
yield from indirector(video_format, k)
base_formats_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "video_formats")
@cached(5)
def get_video_formats():
formats = []
format_files = {}
for format_name in folder_paths.get_filename_list("VHS_video_formats"):
format_name = format_name[:-5]
video_format_path = folder_paths.get_full_path("VHS_video_formats", format_name + ".json")
with open(video_format_path, 'r') as stream:
format_files[format_name] = folder_paths.get_full_path("VHS_video_formats", format_name)
for item in os.scandir(base_formats_dir):
if not item.is_file() or not item.name.endswith('.json'):
continue
format_files[item.name[:-5]] = item.path
formats = []
format_widgets = {}
for format_name, path in format_files.items():
with open(path, 'r') as stream:
video_format = json.load(stream)
if "gifski_pass" in video_format and gifski_path is None:
#Skip format
continue
widgets = [w[0] for w in gen_format_widgets(video_format)]
widgets = list(iterate_format(video_format))
formats.append("video/" + format_name)
if (len(widgets) > 0):
formats.append(["video/" + format_name, widgets])
else:
formats.append("video/" + format_name)
return formats
def get_format_widget_defaults(format_name):
video_format_path = folder_paths.get_full_path("VHS_video_formats", format_name + ".json")
with open(video_format_path, 'r') as stream:
video_format = json.load(stream)
results = {}
for w in gen_format_widgets(video_format):
if len(w[0]) > 2 and 'default' in w[0][2]:
default = w[0][2]['default']
else:
if type(w[0][1]) is list:
default = w[0][1][0]
else:
#NOTE: This doesn't respect max/min, but should be good enough as a fallback to a fallback to a fallback
default = {"BOOLEAN": False, "INT": 0, "FLOAT": 0, "STRING": ""}[w[0][1]]
results[w[0][0]] = default
return results
format_widgets["video/"+ format_name] = widgets
return formats, format_widgets
def apply_format_widgets(format_name, kwargs):
video_format_path = folder_paths.get_full_path("VHS_video_formats", format_name + ".json")
if os.path.exists(os.path.join(base_formats_dir, format_name + ".json")):
video_format_path = os.path.join(base_formats_dir, format_name + ".json")
else:
video_format_path = folder_paths.get_full_path("VHS_video_formats", format_name)
with open(video_format_path, 'r') as stream:
video_format = json.load(stream)
for w in gen_format_widgets(video_format):
assert(w[0][0] in kwargs)
if len(w[0]) > 3:
w[0] = Template(w[0][3]).substitute(val=kwargs[w[0][0]])
else:
w[0] = str(kwargs[w[0][0]])
for w in iterate_format(video_format):
if w[0] not in kwargs:
if len(w) > 2 and 'default' in w[2]:
default = w[2]['default']
else:
if type(w[1]) is list:
default = w[1][0]
else:
#NOTE: This doesn't respect max/min, but should be good enough as a fallback to a fallback to a fallback
default = {"BOOLEAN": False, "INT": 0, "FLOAT": 0, "STRING": ""}[w[1]]
kwargs[w[0]] = default
logger.warn(f"Missing input for {w[0]} has been set to {default}")
wit = iterate_format(video_format, False)
for w in wit:
while isinstance(w, list):
if len(w) == 1:
#TODO: mapping=kwargs should be safer, but results in key errors, investigate why
w = [Template(x).substitute(**kwargs) for x in w[0]]
break
elif isinstance(w[1], dict):
w = w[1][str(kwargs[w[0]])]
elif len(w) > 3:
w = Template(w[3]).substitute(val=kwargs[w[0]])
else:
w = str(kwargs[w[0]])
wit.send(w)
return video_format
def tensor_to_int(tensor, bits):
#TODO: investigate benefit of rounding by adding 0.5 before clip/cast
tensor = tensor.cpu().numpy() * (2**bits-1)
tensor = tensor.cpu().numpy() * (2**bits-1) + 0.5
return np.clip(tensor, 0, (2**bits-1))
def tensor_to_shorts(tensor):
return tensor_to_int(tensor, 16).astype(np.uint16)
@@ -107,19 +136,28 @@ def ffmpeg_process(args, video_format, video_metadata, file_path, env):
total_frames_output = 0
if video_format.get('save_metadata', 'False') != 'False':
os.makedirs(folder_paths.get_temp_directory(), exist_ok=True)
metadata = json.dumps(video_metadata)
metadata_path = os.path.join(folder_paths.get_temp_directory(), "metadata.txt")
#metadata from file should escape = ; # \ and newline
metadata = metadata.replace("\\","\\\\")
metadata = metadata.replace(";","\\;")
metadata = metadata.replace("#","\\#")
metadata = metadata.replace("=","\\=")
metadata = metadata.replace("\n","\\\n")
metadata = "comment=" + metadata
def escape_ffmpeg_metadata(key, value):
value = str(value)
value = value.replace("\\","\\\\")
value = value.replace(";","\\;")
value = value.replace("#","\\#")
value = value.replace("=","\\=")
value = value.replace("\n","\\\n")
return f"{key}={value}"
with open(metadata_path, "w") as f:
f.write(";FFMETADATA1\n")
f.write(metadata)
m_args = args[:1] + ["-i", metadata_path] + args[1:] + ["-metadata", "creation_time=now"]
if "prompt" in video_metadata:
f.write(escape_ffmpeg_metadata("prompt", json.dumps(video_metadata["prompt"])) + "\n")
if "workflow" in video_metadata:
f.write(escape_ffmpeg_metadata("workflow", json.dumps(video_metadata["workflow"])) + "\n")
for k, v in video_metadata.items():
if k not in ["prompt", "workflow"]:
f.write(escape_ffmpeg_metadata(k, json.dumps(v)) + "\n")
m_args = args[:1] + ["-i", metadata_path] + args[1:] + ["-metadata", "creation_time=now", "-movflags", "use_metadata_tags"]
with subprocess.Popen(m_args + [file_path], stderr=subprocess.PIPE,
stdin=subprocess.PIPE, env=env) as proc:
try:
@@ -138,9 +176,9 @@ def ffmpeg_process(args, video_format, video_metadata, file_path, env):
#and seems to never occur concurrent to the metadata issue
if os.path.exists(file_path):
raise Exception("An error occurred in the ffmpeg subprocess:\n" \
+ err.decode("utf-8"))
+ err.decode(*ENCODE_ARGS))
#Res was not set
print(err.decode("utf-8"), end="", file=sys.stderr)
print(err.decode(*ENCODE_ARGS), end="", file=sys.stderr)
logger.warn("An error occurred when saving with metadata")
if res != b'':
with subprocess.Popen(args + [file_path], stderr=subprocess.PIPE,
@@ -156,17 +194,19 @@ def ffmpeg_process(args, video_format, video_metadata, file_path, env):
except BrokenPipeError as e:
res = proc.stderr.read()
raise Exception("An error occurred in the ffmpeg subprocess:\n" \
+ res.decode("utf-8"))
+ res.decode(*ENCODE_ARGS))
yield total_frames_output
if len(res) > 0:
print(res.decode("utf-8"), end="", file=sys.stderr)
print(res.decode(*ENCODE_ARGS), end="", file=sys.stderr)
def gifski_process(args, video_format, file_path, env):
def gifski_process(args, dimensions, frame_rate, video_format, file_path, env):
frame_data = yield
with subprocess.Popen(args + video_format['main_pass'] + ['-f', 'yuv4mpegpipe', '-'],
stderr=subprocess.PIPE, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, env=env) as procff:
with subprocess.Popen([gifski_path] + video_format['gifski_pass']
+ ['-W', f'{dimensions[0]}', '-H', f'{dimensions[1]}']
+ ['-r', f'{frame_rate}']
+ ['-q', '-o', file_path, '-'], stderr=subprocess.PIPE,
stdin=procff.stdout, stdout=subprocess.PIPE,
env=env) as procgs:
@@ -185,14 +225,14 @@ def gifski_process(args, video_format, file_path, env):
resgs = procgs.stderr.read()
raise Exception("An error occurred while creating gifski output\n" \
+ "Make sure you are using gifski --version >=1.32.0\nffmpeg: " \
+ resff.decode("utf-8") + '\ngifski: ' + resgs.decode("utf-8"))
+ resff.decode(*ENCODE_ARGS) + '\ngifski: ' + resgs.decode(*ENCODE_ARGS))
if len(resff) > 0:
print(resff.decode("utf-8"), end="", file=sys.stderr)
print(resff.decode(*ENCODE_ARGS), end="", file=sys.stderr)
if len(resgs) > 0:
print(resgs.decode("utf-8"), end="", file=sys.stderr)
print(resgs.decode(*ENCODE_ARGS), end="", file=sys.stderr)
#should always be empty as the quiet flag is passed
if len(outgs) > 0:
print(outgs.decode("utf-8"))
print(outgs.decode(*ENCODE_ARGS))
def to_pingpong(inp):
if not hasattr(inp, "__getitem__"):
@@ -204,31 +244,31 @@ def to_pingpong(inp):
class VideoCombine:
@classmethod
def INPUT_TYPES(s):
ffmpeg_formats = get_video_formats()
ffmpeg_formats, format_widgets = get_video_formats()
format_widgets["image/webp"] = [['lossless', "BOOLEAN", {'default': True}]]
return {
"required": {
"images": (imageOrLatent,),
"frame_rate": (
"FLOAT",
floatOrInt,
{"default": 8, "min": 1, "step": 1},
),
"loop_count": ("INT", {"default": 0, "min": 0, "max": 100, "step": 1}),
"filename_prefix": ("STRING", {"default": "AnimateDiff"}),
"format": (["image/gif", "image/webp"] + ffmpeg_formats,),
"format": (["image/gif", "image/webp"] + ffmpeg_formats, {'formats': format_widgets}),
"pingpong": ("BOOLEAN", {"default": False}),
"save_output": ("BOOLEAN", {"default": True}),
},
"optional": {
"images": ("IMAGE",),
"audio": ("AUDIO",),
"meta_batch": ("VHS_BatchManager",),
"vae": ("VAE",),
"latents": ("LATENT",),
},
"hidden": {
"hidden": ContainsAll({
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
"unique_id": "UNIQUE_ID"
},
}),
}
RETURN_TYPES = ("VHS_FILENAMES",)
@@ -253,7 +293,8 @@ class VideoCombine:
unique_id=None,
manual_format_widgets=None,
meta_batch=None,
vae=None
vae=None,
**kwargs
):
if latents is not None:
images = latents
@@ -271,8 +312,8 @@ class VideoCombine:
pbar = ProgressBar(num_frames)
if vae is not None:
downscale_ratio = getattr(vae, "downscale_ratio", 8)
width = images.size(3)*downscale_ratio
height = images.size(2)*downscale_ratio
width = images.size(-1)*downscale_ratio
height = images.size(-2)*downscale_ratio
frames_per_batch = (1920 * 1080 * 16) // (width * height) or 1
#Python 3.12 adds an itertools.batched, but it's easily replicated for legacy support
def batched(it, n):
@@ -286,6 +327,9 @@ class VideoCombine:
first_image = next(images)
#repush first_image
images = itertools.chain([first_image], images)
#A single image has 3 dimensions. Discard higher dimensions
while len(first_image.shape) > 3:
first_image = first_image[0]
else:
first_image = images[0]
images = iter(images)
@@ -313,6 +357,9 @@ class VideoCombine:
for x in extra_pnginfo:
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
video_metadata[x] = extra_pnginfo[x]
extra_options = extra_pnginfo.get('workflow', {}).get('extra', {})
else:
extra_options = {}
metadata.add_text("CreationTime", datetime.datetime.now().isoformat(" ")[:19])
if meta_batch is not None and unique_id in meta_batch.outputs:
@@ -338,13 +385,14 @@ class VideoCombine:
output_process = None
# save first frame as png to keep metadata
file = f"{filename}_{counter:05}.png"
file_path = os.path.join(full_output_folder, file)
Image.fromarray(tensor_to_bytes(first_image)).save(
file_path,
pnginfo=metadata,
compress_level=4,
)
first_image_file = f"{filename}_{counter:05}.png"
file_path = os.path.join(full_output_folder, first_image_file)
if extra_options.get('VHS_MetadataImage', True) != False:
Image.fromarray(tensor_to_bytes(first_image)).save(
file_path,
pnginfo=metadata,
compress_level=4,
)
output_files.append(file_path)
format_type, format_ext = format.split("/")
@@ -359,11 +407,16 @@ class VideoCombine:
exif = Image.Exif()
exif[ExifTags.IFD.Exif] = {36867: datetime.datetime.now().isoformat(" ")[:19]}
image_kwargs['exif'] = exif
image_kwargs['lossless'] = kwargs.get("lossless", True)
file = f"{filename}_{counter:05}.{format_ext}"
file_path = os.path.join(full_output_folder, file)
if pingpong:
images = to_pingpong(images)
frames = map(lambda x : Image.fromarray(tensor_to_bytes(x)), images)
def frames_gen(images):
for i in images:
pbar.update(1)
yield Image.fromarray(tensor_to_bytes(i))
frames = frames_gen(images)
# Use pillow directly to save an animated image
next(frames).save(
file_path,
@@ -381,27 +434,14 @@ class VideoCombine:
if ffmpeg_path is None:
raise ProcessLookupError(f"ffmpeg is required for video outputs and could not be found.\nIn order to use video outputs, you must either:\n- Install imageio-ffmpeg with pip,\n- Place a ffmpeg executable in {os.path.abspath('')}, or\n- Install ffmpeg and add it to the system path.")
#Acquire additional format_widget values
kwargs = None
if manual_format_widgets is None:
if prompt is not None:
kwargs = prompt[unique_id]['inputs']
else:
manual_format_widgets = {}
if kwargs is None:
kwargs = get_format_widget_defaults(format_ext)
missing = {}
for k in kwargs.keys():
if k in manual_format_widgets:
kwargs[k] = manual_format_widgets[k]
else:
missing[k] = kwargs[k]
if len(missing) > 0:
logger.warn("Extra format values were not provided, the following defaults will be used: " + str(kwargs) + "\nThis is likely due to usage of ComfyUI-to-python. These values can be manually set by supplying a manual_format_widgets argument")
if manual_format_widgets is not None:
logger.warn("Format args can now be passed directly. The manual_format_widgets argument is now deprecated")
kwargs.update(manual_format_widgets)
video_format = apply_format_widgets(format_ext, kwargs)
has_alpha = first_image.shape[-1] == 4
dim_alignment = video_format.get("dim_alignment", 8)
kwargs["has_alpha"] = has_alpha
video_format = apply_format_widgets(format_ext, kwargs)
dim_alignment = video_format.get("dim_alignment", 2)
if (first_image.shape[1] % dim_alignment) or (first_image.shape[0] % dim_alignment):
#output frames must be padded
to_pad = (-first_image.shape[1] % dim_alignment,
@@ -414,20 +454,22 @@ class VideoCombine:
padded = padfunc(image.to(dtype=torch.float32))
return padded.permute((1,2,0))
images = map(pad, images)
new_dims = (-first_image.shape[1] % dim_alignment + first_image.shape[1],
-first_image.shape[0] % dim_alignment + first_image.shape[0])
dimensions = f"{new_dims[0]}x{new_dims[1]}"
dimensions = (-first_image.shape[1] % dim_alignment + first_image.shape[1],
-first_image.shape[0] % dim_alignment + first_image.shape[0])
logger.warn("Output images were not of valid resolution and have had padding applied")
else:
dimensions = f"{first_image.shape[1]}x{first_image.shape[0]}"
if loop_count > 0:
loop_args = ["-vf", "loop=loop=" + str(loop_count)+":size=" + str(num_frames)]
else:
loop_args = []
dimensions = (first_image.shape[1], first_image.shape[0])
if pingpong:
if meta_batch is not None:
logger.error("pingpong is incompatible with batched output")
images = to_pingpong(images)
if num_frames > 2:
num_frames += num_frames -2
pbar.total = num_frames
if loop_count > 0:
loop_args = ["-vf", "loop=loop=" + str(loop_count)+":size=" + str(num_frames)]
else:
loop_args = []
if video_format.get('input_color_depth', '8bit') == '16bit':
images = map(tensor_to_shorts, images)
if has_alpha:
@@ -447,7 +489,18 @@ class VideoCombine:
if bitrate is not None:
bitrate_arg = ["-b:v", str(bitrate) + "M" if video_format.get('megabit') == 'True' else str(bitrate) + "K"]
args = [ffmpeg_path, "-v", "error", "-f", "rawvideo", "-pix_fmt", i_pix_fmt,
"-s", dimensions, "-r", str(frame_rate), "-i", "-"] \
# The image data is in an undefined generic RGB color space, which in practice means sRGB.
# sRGB has the same primaries and matrix as BT.709, but a different transfer function (gamma),
# called by the sRGB standard name IEC 61966-2-1. However, video hosting platforms like YouTube
# standardize on full BT.709 and will convert the colors accordingly. This last minute change
# in colors can be confusing to users. We can counter it by lying about the transfer function
# on a per format basis, i.e. for video we will lie to FFmpeg that it is already BT.709. Also,
# because the input data is in RGB (not YUV) it is more efficient (fewer scale filter invocations)
# to specify the input color space as RGB and then later, if the format actually wants YUV,
# to convert it to BT.709 YUV via FFmpeg's -vf "scale=out_color_matrix=bt709".
"-color_range", "pc", "-colorspace", "rgb", "-color_primaries", "bt709",
"-color_trc", video_format.get("fake_trc", "iec61966-2-1"),
"-s", f"{dimensions[0]}x{dimensions[1]}", "-r", str(frame_rate), "-i", "-"] \
+ loop_args
images = map(lambda x: x.tobytes(), images)
@@ -464,21 +517,27 @@ class VideoCombine:
raise Exception("Formats which require a pre_pass are incompatible with Batch Manager.")
images = [b''.join(images)]
os.makedirs(folder_paths.get_temp_directory(), exist_ok=True)
pre_pass_args = args[:13] + video_format['pre_pass']
in_args_len = args.index("-i") + 2 # The index after ["-i", "-"]
pre_pass_args = args[:in_args_len] + video_format['pre_pass']
merge_filter_args(pre_pass_args)
try:
subprocess.run(pre_pass_args, input=images[0], env=env,
capture_output=True, check=True)
except subprocess.CalledProcessError as e:
raise Exception("An error occurred in the ffmpeg prepass:\n" \
+ e.stderr.decode("utf-8"))
+ e.stderr.decode(*ENCODE_ARGS))
if "inputs_main_pass" in video_format:
args = args[:13] + video_format['inputs_main_pass'] + args[13:]
in_args_len = args.index("-i") + 2 # The index after ["-i", "-"]
args = args[:in_args_len] + video_format['inputs_main_pass'] + args[in_args_len:]
if output_process is None:
if 'gifski_pass' in video_format:
output_process = gifski_process(args, video_format, file_path, env)
format = 'image/gif'
output_process = gifski_process(args, dimensions, frame_rate, video_format, file_path, env)
audio = None
else:
args += video_format['main_pass'] + bitrate_arg
merge_filter_args(args)
output_process = ffmpeg_process(args, video_format, video_metadata, file_path, env)
#Proceed to first yield
output_process.send(None)
@@ -508,7 +567,15 @@ class VideoCombine:
output_files.append(file_path)
a_waveform = None
if audio is not None:
try:
#safely check if audio produced by VHS_LoadVideo actually exists
a_waveform = audio['waveform']
except:
pass
if a_waveform is not None:
# Create audio file if input was provided
output_file_with_audio = f"{filename}_{counter:05}-audio.{video_format['extension']}"
output_file_with_audio_path = os.path.join(full_output_folder, output_file_with_audio)
@@ -522,44 +589,48 @@ class VideoCombine:
#Reconsider forcing apad/shortest
channels = audio['waveform'].size(1)
min_audio_dur = total_frames_output / frame_rate + 1
if video_format.get('trim_to_audio', 'False') != 'False':
apad = []
else:
apad = ["-af", "apad=whole_dur="+str(min_audio_dur)]
mux_args = [ffmpeg_path, "-v", "error", "-n", "-i", file_path,
"-ar", str(audio['sample_rate']), "-ac", str(channels),
"-f", "f32le", "-i", "-", "-c:v", "copy"] \
+ video_format["audio_pass"] \
+ ["-af", "apad=whole_dur="+str(min_audio_dur),
"-shortest", output_file_with_audio_path]
+ apad + ["-shortest", output_file_with_audio_path]
audio_data = audio['waveform'].squeeze(0).transpose(0,1) \
.numpy().tobytes()
merge_filter_args(mux_args, '-af')
try:
res = subprocess.run(mux_args, input=audio_data,
env=env, capture_output=True, check=True)
except subprocess.CalledProcessError as e:
raise Exception("An error occured in the ffmpeg subprocess:\n" \
+ e.stderr.decode("utf-8"))
+ e.stderr.decode(*ENCODE_ARGS))
if res.stderr:
print(res.stderr.decode("utf-8"), end="", file=sys.stderr)
print(res.stderr.decode(*ENCODE_ARGS), end="", file=sys.stderr)
output_files.append(output_file_with_audio_path)
#Return this file with audio to the webui.
#It will be muted unless opened or saved with right click
file = output_file_with_audio
previews = [
{
if extra_options.get('VHS_KeepIntermediate', True) == False:
for intermediate in output_files[1:-1]:
if os.path.exists(intermediate):
os.remove(intermediate)
preview = {
"filename": file,
"subfolder": subfolder,
"type": "output" if save_output else "temp",
"format": format,
"frame_rate": frame_rate,
"workflow": first_image_file,
"fullpath": output_files[-1],
}
]
if num_frames == 1 and 'png' in format and '%03d' in file:
previews[0]['format'] = 'image/png'
previews[0]['filename'] = file.replace('%03d', '001')
return {"ui": {"gifs": previews}, "result": ((save_output, output_files),)}
@classmethod
def VALIDATE_INPUTS(self, format, **kwargs):
return True
preview['format'] = 'image/png'
preview['filename'] = file.replace('%03d', '001')
return {"ui": {"gifs": [preview]}, "result": ((save_output, output_files),)}
class LoadAudio:
@classmethod
@@ -569,23 +640,30 @@ class LoadAudio:
"required": {
"audio_file": ("STRING", {"default": "input/", "vhs_path_extensions": ['wav','mp3','ogg','m4a','flac']}),
},
"optional" : {"seek_seconds": ("FLOAT", {"default": 0, "min": 0})}
"optional" : {
"seek_seconds": ("FLOAT", {"default": 0, "min": 0, "widgetType": "VHSTIMESTAMP"}),
"duration": ("FLOAT" , {"default": 0, "min": 0, "max": 10000000, "step": 0.01, "widgetType": "VHSTIMESTAMP"}),
}
}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
RETURN_TYPES = ("AUDIO", "FLOAT")
RETURN_NAMES = ("audio", "duration")
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢/audio"
FUNCTION = "load_audio"
def load_audio(self, audio_file, seek_seconds):
def load_audio(self, audio_file, seek_seconds=0, duration=0):
audio_file = strip_path(audio_file)
if audio_file is None or validate_path(audio_file) != True:
raise Exception("audio_file is not a valid path: " + audio_file)
if is_url(audio_file):
audio_file = try_download_video(audio_file) or audio_file
#Eagerly fetch the audio since the user must be using it if the
#node executes, unlike Load Video
return (get_audio(audio_file, start_time=seek_seconds),)
audio = get_audio(audio_file, start_time=seek_seconds, duration=duration)
loaded_duration = audio['waveform'].size(2)/audio['sample_rate']
return (audio, loaded_duration)
@classmethod
def IS_CHANGED(s, audio_file, seek_seconds):
def IS_CHANGED(s, audio_file, **kwargs):
return hash_path(audio_file)
@classmethod
@@ -603,27 +681,30 @@ class LoadAudioUpload:
if len(file_parts) > 1 and (file_parts[-1] in audio_extensions):
files.append(f)
return {"required": {
"audio": (sorted(files),),
"start_time": ("FLOAT" , {"default": 0, "min": 0, "max": 10000000, "step": 0.01}),
"duration": ("FLOAT" , {"default": 0, "min": 0, "max": 10000000, "step": 0.01}),
"audio": (sorted(files),),},
"optional": {
"start_time": ("FLOAT" , {"default": 0, "min": 0, "max": 10000000, "step": 0.01, "widgetType": "VHSTIMESTAMP"}),
"duration": ("FLOAT" , {"default": 0, "min": 0, "max": 10000000, "step": 0.01, "widgetType": "VHSTIMESTAMP"}),
},
}
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢/audio"
RETURN_TYPES = ("AUDIO", )
RETURN_NAMES = ("audio",)
RETURN_TYPES = ("AUDIO", "FLOAT")
RETURN_NAMES = ("audio", "duration")
FUNCTION = "load_audio"
def load_audio(self, start_time, duration, **kwargs):
def load_audio(self, start_time=0, duration=0, **kwargs):
audio_file = folder_paths.get_annotated_filepath(strip_path(kwargs['audio']))
if audio_file is None or validate_path(audio_file) != True:
raise Exception("audio_file is not a valid path: " + audio_file)
return (get_audio(audio_file, start_time, duration),)
audio = get_audio(audio_file, start_time, duration)
loaded_duration = audio['waveform'].size(2)/audio['sample_rate']
return (audio, loaded_duration)
@classmethod
def IS_CHANGED(s, audio, start_time, duration):
def IS_CHANGED(s, audio, **kwargs):
audio_file = folder_paths.get_annotated_filepath(strip_path(audio))
return hash_path(audio_file)
@@ -656,14 +737,45 @@ class AudioToVHSAudio:
capture_output=True, check=True)
except subprocess.CalledProcessError as e:
raise Exception("An error occured in the ffmpeg subprocess:\n" \
+ e.stderr.decode("utf-8"))
+ e.stderr.decode(*ENCODE_ARGS))
if res.stderr:
print(res.stderr.decode("utf-8"), end="", file=sys.stderr)
return (lambda x : res.stdout,)
print(res.stderr.decode(*ENCODE_ARGS), end="", file=sys.stderr)
return (lambda: res.stdout,)
class VHSAudioToAudio:
"""Legacy method for external nodes that utilized VHS_AUDIO,
VHS_AUDIO is deprecated as a format and should no longer be used"""
@classmethod
def INPUT_TYPES(s):
return {"required": {"vhs_audio": ("VHS_AUDIO",)}}
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢/audio"
RETURN_TYPES = ("AUDIO", )
RETURN_NAMES = ("audio",)
FUNCTION = "convert_audio"
def convert_audio(self, vhs_audio):
if not vhs_audio or not vhs_audio():
raise Exception("audio input is not valid")
args = [ffmpeg_path, "-i", '-']
try:
res = subprocess.run(args + ["-f", "f32le", "-"], input=vhs_audio(),
capture_output=True, check=True)
audio = torch.frombuffer(bytearray(res.stdout), dtype=torch.float32)
except subprocess.CalledProcessError as e:
raise Exception("An error occured in the ffmpeg subprocess:\n" \
+ e.stderr.decode(*ENCODE_ARGS))
match = re.search(', (\\d+) Hz, (\\w+), ',res.stderr.decode(*ENCODE_ARGS))
if match:
ar = int(match.group(1))
#NOTE: Just throwing an error for other channel types right now
#Will deal with issues if they come
ac = {"mono": 1, "stereo": 2}[match.group(2)]
else:
ar = 44100
ac = 2
audio = audio.reshape((-1,ac)).transpose(0,1).unsqueeze(0)
return ({'waveform': audio, 'sample_rate': ar},)
class PruneOutputs:
@classmethod
@@ -692,7 +804,8 @@ class PruneOutputs:
if options in ["All"]:
delete_list.append(filenames[1][-1])
output_dirs = [os.path.abspath("output"), os.path.abspath("temp")]
output_dirs = [folder_paths.get_output_directory(),
folder_paths.get_temp_directory()]
for file in delete_list:
#Check that path is actually an output directory
if (os.path.commonpath([output_dirs[0], file]) != output_dirs[0]) \
@@ -709,6 +822,7 @@ class BatchManager:
self.outputs = {}
self.unique_id = None
self.has_closed_inputs = False
self.total_frames = float('inf')
def reset(self):
self.close_inputs()
for key in self.outputs:
@@ -733,7 +847,7 @@ class BatchManager:
def INPUT_TYPES(s):
return {
"required": {
"frames_per_batch": ("INT", {"default": 16, "min": 1, "max": 128, "step": 1})
"frames_per_batch": ("INT", {"default": 16, "min": 1, "max": BIGMAX, "step": 1})
},
"hidden": {
"prompt": "PROMPT",
@@ -755,6 +869,9 @@ class BatchManager:
self.reset()
self.frames_per_batch = frames_per_batch
self.unique_id = unique_id
else:
num_batches = (self.total_frames+self.frames_per_batch-1)//frames_per_batch
print(f'Meta-Batch {requeue}/{num_batches}')
#onExecuted seems to not be called unless some message is sent
return (self,)
@@ -787,7 +904,7 @@ class VideoInfo:
def get_video_info(self, video_info):
keys = ["fps", "frame_count", "duration", "width", "height"]
source_info = []
loaded_info = []
@@ -821,7 +938,7 @@ class VideoInfoSource:
def get_video_info(self, video_info):
keys = ["fps", "frame_count", "duration", "width", "height"]
source_info = []
for key in keys:
@@ -853,7 +970,7 @@ class VideoInfoLoaded:
def get_video_info(self, video_info):
keys = ["fps", "frame_count", "duration", "width", "height"]
loaded_info = []
for key in keys:
@@ -861,22 +978,80 @@ class VideoInfoLoaded:
return (*loaded_info,)
class SelectFilename:
@classmethod
def INPUT_TYPES(s):
return {"required": {"filenames": ("VHS_FILENAMES",), "index": ("INT", {"default": -1, "step": 1, "min": -1})}}
RETURN_TYPES = ("STRING",)
RETURN_NAMES =("Filename",)
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢"
FUNCTION = "select_filename"
def select_filename(self, filenames, index):
return (filenames[1][index],)
class Unbatch:
class Any(str):
def __ne__(self, other):
return False
@classmethod
def INPUT_TYPES(s):
return {"required": {"batched": ("*",)}}
RETURN_TYPES = (Any('*'),)
INPUT_IS_LIST = True
RETURN_NAMES =("unbatched",)
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢"
FUNCTION = "unbatch"
def unbatch(self, batched):
if isinstance(batched[0], torch.Tensor):
return (torch.cat(batched),)
if isinstance(batched[0], dict):
out = batched[0].copy()
if 'samples' in out:
out['samples'] = torch.cat([x['samples'] for x in batched])
if 'waveform' in out:
out['waveform'] = torch.cat([x['waveform'] for x in batched])
out.pop('batch_index', None)
return (out,)
return (functools.reduce(lambda x,y: x+y, batched),)
@classmethod
def VALIDATE_INPUTS(cls, input_types):
return True
class SelectLatest:
@classmethod
def INPUT_TYPES(s):
return {"required": {"filename_prefix": ("STRING", {'default': 'output/AnimateDiff', 'vhs_path_extensions': []}),
"filename_postfix": ("STRING", {"placeholder": ".webm"})}}
RETURN_TYPES = ("STRING",)
RETURN_NAMES =("Filename",)
CATEGORY = "Video Helper Suite 🎥🅥🅗🅢"
FUNCTION = "select_latest"
EXPERIMENTAL = True
def select_latest(self, filename_prefix, filename_postfix):
assert False, "Not Reachable"
NODE_CLASS_MAPPINGS = {
"VHS_VideoCombine": VideoCombine,
"VHS_LoadVideo": LoadVideoUpload,
"VHS_LoadVideoPath": LoadVideoPath,
"VHS_LoadVideoFFmpeg": LoadVideoFFmpegUpload,
"VHS_LoadVideoFFmpegPath": LoadVideoFFmpegPath,
"VHS_LoadImagePath": LoadImagePath,
"VHS_LoadImages": LoadImagesFromDirectoryUpload,
"VHS_LoadImagesPath": LoadImagesFromDirectoryPath,
"VHS_LoadAudio": LoadAudio,
"VHS_LoadAudioUpload": LoadAudioUpload,
"VHS_AudioToVHSAudio": AudioToVHSAudio,
"VHS_VHSAudioToAudio": VHSAudioToAudio,
"VHS_PruneOutputs": PruneOutputs,
"VHS_BatchManager": BatchManager,
"VHS_VideoInfo": VideoInfo,
"VHS_VideoInfoSource": VideoInfoSource,
"VHS_VideoInfoLoaded": VideoInfoLoaded,
"VHS_SelectFilename": SelectFilename,
# Batched Nodes
"VHS_VAEEncodeBatched": VAEEncodeBatched,
"VHS_VAEDecodeBatched": VAEDecodeBatched,
# Latent and Image nodes
"VHS_SplitLatents": SplitLatents,
"VHS_SplitImages": SplitImages,
@@ -884,50 +1059,62 @@ NODE_CLASS_MAPPINGS = {
"VHS_MergeLatents": MergeLatents,
"VHS_MergeImages": MergeImages,
"VHS_MergeMasks": MergeMasks,
"VHS_SelectEveryNthLatent": SelectEveryNthLatent,
"VHS_SelectEveryNthImage": SelectEveryNthImage,
"VHS_SelectEveryNthMask": SelectEveryNthMask,
"VHS_GetLatentCount": GetLatentCount,
"VHS_GetImageCount": GetImageCount,
"VHS_GetMaskCount": GetMaskCount,
"VHS_DuplicateLatents": DuplicateLatents,
"VHS_DuplicateImages": DuplicateImages,
"VHS_DuplicateMasks": DuplicateMasks,
# Batched Nodes
"VHS_VAEEncodeBatched": VAEEncodeBatched,
"VHS_VAEDecodeBatched": VAEDecodeBatched,
"VHS_DuplicateLatents": RepeatLatents,
"VHS_DuplicateImages": RepeatImages,
"VHS_DuplicateMasks": RepeatMasks,
"VHS_SelectEveryNthLatent": SelectEveryNthLatent,
"VHS_SelectEveryNthImage": SelectEveryNthImage,
"VHS_SelectEveryNthMask": SelectEveryNthMask,
"VHS_SelectLatents": SelectLatents,
"VHS_SelectImages": SelectImages,
"VHS_SelectMasks": SelectMasks,
"VHS_Unbatch": Unbatch,
"VHS_SelectLatest": SelectLatest,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"VHS_VideoCombine": "Video Combine 🎥🅥🅗🅢",
"VHS_LoadVideo": "Load Video (Upload) 🎥🅥🅗🅢",
"VHS_LoadVideoPath": "Load Video (Path) 🎥🅥🅗🅢",
"VHS_LoadVideoFFmpeg": "Load Video FFmpeg (Upload) 🎥🅥🅗🅢",
"VHS_LoadVideoFFmpegPath": "Load Video FFmpeg (Path) 🎥🅥🅗🅢",
"VHS_LoadImagePath": "Load Image (Path) 🎥🅥🅗🅢",
"VHS_LoadImages": "Load Images (Upload) 🎥🅥🅗🅢",
"VHS_LoadImagesPath": "Load Images (Path) 🎥🅥🅗🅢",
"VHS_LoadAudio": "Load Audio (Path)🎥🅥🅗🅢",
"VHS_LoadAudioUpload": "Load Audio (Upload)🎥🅥🅗🅢",
"VHS_AudioToVHSAudio": "Audio to legacy VHS_AUDIO🎥🅥🅗🅢",
"VHS_VHSAudioToAudio": "Legacy VHS_AUDIO to Audio🎥🅥🅗🅢",
"VHS_PruneOutputs": "Prune Outputs 🎥🅥🅗🅢",
"VHS_BatchManager": "Meta Batch Manager 🎥🅥🅗🅢",
"VHS_VideoInfo": "Video Info 🎥🅥🅗🅢",
"VHS_VideoInfoSource": "Video Info (Source) 🎥🅥🅗🅢",
"VHS_VideoInfoLoaded": "Video Info (Loaded) 🎥🅥🅗🅢",
# Latent and Image nodes
"VHS_SplitLatents": "Split Latent Batch 🎥🅥🅗🅢",
"VHS_SplitImages": "Split Image Batch 🎥🅥🅗🅢",
"VHS_SplitMasks": "Split Mask Batch 🎥🅥🅗🅢",
"VHS_MergeLatents": "Merge Latent Batches 🎥🅥🅗🅢",
"VHS_MergeImages": "Merge Image Batches 🎥🅥🅗🅢",
"VHS_MergeMasks": "Merge Mask Batches 🎥🅥🅗🅢",
"VHS_SelectEveryNthLatent": "Select Every Nth Latent 🎥🅥🅗🅢",
"VHS_SelectEveryNthImage": "Select Every Nth Image 🎥🅥🅗🅢",
"VHS_SelectEveryNthMask": "Select Every Nth Mask 🎥🅥🅗🅢",
"VHS_GetLatentCount": "Get Latent Count 🎥🅥🅗🅢",
"VHS_GetImageCount": "Get Image Count 🎥🅥🅗🅢",
"VHS_GetMaskCount": "Get Mask Count 🎥🅥🅗🅢",
"VHS_DuplicateLatents": "Duplicate Latent Batch 🎥🅥🅗🅢",
"VHS_DuplicateImages": "Duplicate Image Batch 🎥🅥🅗🅢",
"VHS_DuplicateMasks": "Duplicate Mask Batch 🎥🅥🅗🅢",
"VHS_SelectFilename": "Select Filename 🎥🅥🅗🅢",
# Batched Nodes
"VHS_VAEEncodeBatched": "VAE Encode Batched 🎥🅥🅗🅢",
"VHS_VAEDecodeBatched": "VAE Decode Batched 🎥🅥🅗🅢",
# Latent and Image nodes
"VHS_SplitLatents": "Split Latents 🎥🅥🅗🅢",
"VHS_SplitImages": "Split Images 🎥🅥🅗🅢",
"VHS_SplitMasks": "Split Masks 🎥🅥🅗🅢",
"VHS_MergeLatents": "Merge Latents 🎥🅥🅗🅢",
"VHS_MergeImages": "Merge Images 🎥🅥🅗🅢",
"VHS_MergeMasks": "Merge Masks 🎥🅥🅗🅢",
"VHS_GetLatentCount": "Get Latent Count 🎥🅥🅗🅢",
"VHS_GetImageCount": "Get Image Count 🎥🅥🅗🅢",
"VHS_GetMaskCount": "Get Mask Count 🎥🅥🅗🅢",
"VHS_DuplicateLatents": "Repeat Latents 🎥🅥🅗🅢",
"VHS_DuplicateImages": "Repeat Images 🎥🅥🅗🅢",
"VHS_DuplicateMasks": "Repeat Masks 🎥🅥🅗🅢",
"VHS_SelectEveryNthLatent": "Select Every Nth Latent 🎥🅥🅗🅢",
"VHS_SelectEveryNthImage": "Select Every Nth Image 🎥🅥🅗🅢",
"VHS_SelectEveryNthMask": "Select Every Nth Mask 🎥🅥🅗🅢",
"VHS_SelectLatents": "Select Latents 🎥🅥🅗🅢",
"VHS_SelectImages": "Select Images 🎥🅥🅗🅢",
"VHS_SelectMasks": "Select Masks 🎥🅥🅗🅢",
"VHS_Unbatch": "Unbatch 🎥🅥🅗🅢",
"VHS_SelectLatest": "Select Latest 🎥🅥🅗🅢",
}
+216 -76
View File
@@ -1,64 +1,42 @@
import server
import folder_paths
import os
import time
import subprocess
from .utils import is_url, get_sorted_dir_files_from_directory, ffmpeg_path, validate_sequence, is_safe_path, strip_path
import re
import asyncio
import av
from .utils import is_url, get_sorted_dir_files_from_directory, ffmpeg_path, \
validate_sequence, is_safe_path, strip_path, try_download_video, ENCODE_ARGS
from comfy.k_diffusion.utils import FolderOfImages
web = server.web
@server.PromptServer.instance.routes.get("/vhs/viewvideo")
@server.PromptServer.instance.routes.get("/viewvideo")
async def view_video(request):
query = request.rel_url.query
if "filename" not in query:
return web.Response(status=404)
filename = query["filename"]
path_res = await resolve_path(query)
if isinstance(path_res, web.Response):
return path_res
file, filename, output_dir = path_res
#Path code misformats urls on windows and must be skipped
if is_url(filename):
file = filename
else:
filename, output_dir = folder_paths.annotated_filepath(filename)
type = request.rel_url.query.get("type", "output")
if type == "path":
#special case for path_based nodes
#NOTE: output_dir may be empty, but non-None
output_dir, filename = os.path.split(strip_path(filename))
if output_dir is None:
output_dir = folder_paths.get_directory_by_type(type)
if output_dir is None:
return web.Response(status=400)
if not is_safe_path(output_dir):
return web.Response(status=403)
if "subfolder" in request.rel_url.query:
output_dir = os.path.join(output_dir, request.rel_url.query["subfolder"])
filename = os.path.basename(filename)
file = os.path.join(output_dir, filename)
if query.get('format', 'video') == 'folder':
if not os.path.isdir(file):
return web.Response(status=404)
else:
if not os.path.isfile(file) and not validate_sequence(file):
return web.Response(status=404)
if ffmpeg_path is None:
#Don't just return file, that provides arbitrary read access to any file
if is_safe_path(output_dir, strict=True):
return web.FileResponse(path=file)
frame_rate = query.get('frame_rate', 8)
if query.get('format', 'video') == "folder":
#Check that folder contains some valid image file, get it's extension
#ffmpeg seems to not support list globs, so support for mixed extensions seems unfeasible
os.makedirs(folder_paths.get_temp_directory(), exist_ok=True)
concat_file = os.path.join(folder_paths.get_temp_directory(), "image_sequence_preview.txt")
skip_first_images = int(query.get('skip_first_images', 0))
select_every_nth = int(query.get('select_every_nth', 1))
select_every_nth = int(query.get('select_every_nth', 1)) or 1
valid_images = get_sorted_dir_files_from_directory(file, skip_first_images, select_every_nth, FolderOfImages.IMG_EXTENSIONS)
if len(valid_images) == 0:
return web.Response(status=400)
return web.Response(status=204)
with open(concat_file, "w") as f:
f.write("ffconcat version 1.0\n")
for path in valid_images:
@@ -69,15 +47,49 @@ async def view_video(request):
in_args = ["-i", file]
if '%' in file:
in_args = ['-framerate', str(frame_rate)] + in_args
#Do prepass to pull info
#breaks skip_first frames if this default is ever actually needed
base_fps = 30
try:
proc = await asyncio.create_subprocess_exec(ffmpeg_path, *in_args, '-t',
'0','-f', 'null','-', stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stdin=subprocess.DEVNULL)
_, res_stderr = await proc.communicate()
args = [ffmpeg_path, "-v", "error"] + in_args
match = re.search(': Video: (\\w+) .+, (\\d+) fps,', res_stderr.decode(*ENCODE_ARGS))
if match:
base_fps = float(match.group(2))
if match.group(1) == 'vp9':
#force libvpx for transparency
in_args = ['-c:v', 'libvpx-vp9'] + in_args
except subprocess.CalledProcessError as e:
print("An error occurred in the ffmpeg prepass:\n" \
+ e.stderr.decode(*ENCODE_ARGS))
return web.Response(status=500)
vfilters = []
if int(query.get('force_rate',0)) != 0:
vfilters.append("fps=fps="+query['force_rate'] + ":round=up:start_time=0.001")
if int(query.get('skip_first_frames', 0)) > 0:
vfilters.append(f"select=gt(n\\,{int(query['skip_first_frames'])-1})")
if int(query.get('select_every_nth', 1)) > 1:
vfilters.append(f"select=not(mod(n\\,{query['select_every_nth']}))")
target_rate = float(query.get('force_rate', 0)) or base_fps
modified_rate = target_rate / (float(query.get('select_every_nth',1)) or 1)
start_time = 0
if 'start_time' in query:
start_time = float(query['start_time'])
elif float(query.get('skip_first_frames', 0)) > 0:
start_time = float(query.get('skip_first_frames'))/target_rate
if start_time > 1/modified_rate:
start_time += 1/modified_rate
if start_time > 0:
if start_time > 4:
post_seek = ['-ss', '4']
pre_seek = ['-ss', str(start_time - 4)]
else:
post_seek = ['-ss', str(start_time)]
pre_seek = []
else:
pre_seek = []
post_seek = []
args = [ffmpeg_path, "-v", "error"] + pre_seek + in_args + post_seek
if target_rate != 0:
args += ['-r', str(modified_rate)]
if query.get('force_size','Disabled') != "Disabled":
size = query['force_size'].split('x')
if size[0] == '?' or size[1] == '?':
@@ -90,47 +102,175 @@ async def view_video(request):
vfilters.append(f"crop=if(gt({ar}\\,a)\\,iw\\,ih*{ar}):if(gt({ar}\\,a)\\,iw/{ar}\\,ih)")
size = ':'.join(size)
vfilters.append(f"scale={size}")
vfilters.append("setpts=PTS-STARTPTS")
if len(vfilters) > 0:
args += ["-vf", ",".join(vfilters)]
if int(query.get('frame_load_cap', 0)) > 0:
args += ["-frames:v", query['frame_load_cap']]
if float(query.get('frame_load_cap', 0)) > 0:
args += ["-frames:v", query['frame_load_cap'].split('.')[0]]
#TODO:reconsider adding high frame cap/setting default frame cap on node
if query.get('deadline', 'realtime') == 'good':
deadline = 'good'
else:
deadline = 'realtime'
args += ['-c:v', 'libvpx-vp9','-deadline', 'realtime', '-cpu-used', '8', '-f', 'webm', '-']
args += ['-c:v', 'libvpx-vp9','-deadline', deadline, '-cpu-used', '8', '-f', 'webm', '-']
try:
with subprocess.Popen(args, stdout=subprocess.PIPE) as proc:
try:
resp = web.StreamResponse()
resp.content_type = 'video/webm'
resp.headers["Content-Disposition"] = f"filename=\"{filename}\""
await resp.prepare(request)
while True:
bytes_read = proc.stdout.read()
if bytes_read is None:
#TODO: check for timeout here
time.sleep(.1)
continue
if len(bytes_read) == 0:
break
await resp.write(bytes_read)
except ConnectionResetError as e:
#Kill ffmpeg before stdout closes
proc.kill()
except ConnectionError as e:
#Kill ffmpeg before stdout closes
proc.kill()
proc = await asyncio.create_subprocess_exec(*args, stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL)
try:
resp = web.StreamResponse()
resp.content_type = 'video/webm'
resp.headers["Content-Disposition"] = f"filename=\"{filename}\""
await resp.prepare(request)
while len(bytes_read := await proc.stdout.read(2**20)) != 0:
await resp.write(bytes_read)
#Of dubious value given frequency of kill calls, but more correct
await proc.wait()
except (ConnectionResetError, ConnectionError) as e:
proc.kill()
except BrokenPipeError as e:
pass
return resp
@server.PromptServer.instance.routes.get("/vhs/viewaudio")
async def view_audio(request):
query = request.rel_url.query
path_res = await resolve_path(query)
if isinstance(path_res, web.Response):
return path_res
file, filename, output_dir = path_res
if ffmpeg_path is None:
#Don't just return file, that provides arbitrary read access to any file
if is_safe_path(output_dir, strict=True):
return web.FileResponse(path=file)
in_args = ["-i", file]
start_time = 0
if 'start_time' in query:
start_time = float(query['start_time'])
args = [ffmpeg_path, "-v", "error", '-vn'] + in_args + ['-ss', str(start_time)]
if float(query.get('duration', 0)) > 0:
args += ['-t', str(query['duration'])]
if query.get('deadline', 'realtime') == 'good':
deadline = 'good'
else:
deadline = 'realtime'
args += ['-c:a', 'libopus','-deadline', deadline, '-cpu-used', '8', '-f', 'webm', '-']
try:
proc = await asyncio.create_subprocess_exec(*args, stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL)
try:
resp = web.StreamResponse()
resp.content_type = 'audio/webm'
resp.headers["Content-Disposition"] = f"filename=\"{filename}\""
await resp.prepare(request)
while len(bytes_read := await proc.stdout.read(2**20)) != 0:
await resp.write(bytes_read)
#Of dubious value given frequency of kill calls, but more correct
await proc.wait()
except (ConnectionResetError, ConnectionError) as e:
proc.kill()
except BrokenPipeError as e:
pass
return resp
query_cache = {}
@server.PromptServer.instance.routes.get("/vhs/queryvideo")
async def query_video(request):
query = request.rel_url.query
filepath = await resolve_path(query)
#TODO: cache lookup
if isinstance(filepath, web.Response):
return filepath
filepath = filepath[0]
if filepath.endswith(".webp"):
# ffmpeg doesn't support decoding animated WebP https://trac.ffmpeg.org/ticket/4907
return web.json_response({})
if filepath in query_cache and query_cache[filepath][0] == os.stat(filepath).st_mtime:
source = query_cache[filepath][1]
else:
source = {}
try:
with av.open(filepath) as cont:
stream = cont.streams.video[0]
source['fps'] = float(stream.average_rate)
source['duration'] = float(cont.duration / av.time_base)
if stream.codec_context.name == 'vp9':
cc = av.Codec('libvpx-vp9', 'r').create()
else:
cc = stream
def fit():
for packet in cont.demux(video=0):
yield from cc.decode(packet)
frame = next(fit())
source['size'] = [frame.width, frame.height]
source['alpha'] = 'a' in frame.format.name
source['frames'] = stream.metadata.get('NUMBER_OF_FRAMES', round(source['duration'] * source['fps']))
query_cache[filepath] = (os.stat(filepath).st_mtime, source)
except Exception:
pass
if not 'frames' in source:
return web.json_response({})
loaded = {}
loaded['duration'] = source['duration']
loaded['duration'] -= float(query.get('start_time',0))
loaded['fps'] = float(query.get('force_rate', 0)) or source.get('fps',1)
loaded['duration'] -= int(query.get('skip_first_frames', 0)) / loaded['fps']
loaded['fps'] /= int(query.get('select_every_nth', 1)) or 1
loaded['frames'] = round(loaded['duration'] * loaded['fps'])
return web.json_response({'source': source, 'loaded': loaded})
async def resolve_path(query):
if "filename" not in query:
return web.Response(status=204)
filename = query["filename"]
#Path code misformats urls on windows and must be skipped
if is_url(filename):
file = await asyncio.to_thread(try_download_video, filename) or file
filname, output_dir = os.path.split(file)
return file, filename, output_dir
else:
filename, output_dir = folder_paths.annotated_filepath(filename)
type = query.get("type", "output")
if type == "path":
#special case for path_based nodes
#NOTE: output_dir may be empty, but non-None
output_dir, filename = os.path.split(strip_path(filename))
if output_dir is None:
output_dir = folder_paths.get_directory_by_type(type)
if output_dir is None:
return web.Response(status=204)
if not is_safe_path(output_dir):
return web.Response(status=204)
if "subfolder" in query:
output_dir = os.path.join(output_dir, query["subfolder"])
filename = os.path.basename(filename)
file = os.path.join(output_dir, filename)
if not os.path.exists(file):
return web.Response(status=204)
if query.get('format', 'video') == 'folder':
if not os.path.isdir(file):
return web.Response(status=204)
else:
if not os.path.isfile(file) and not validate_sequence(file):
return web.Response(status=204)
return file, filename, output_dir
@server.PromptServer.instance.routes.get("/vhs/getpath")
@server.PromptServer.instance.routes.get("/getpath")
async def get_path(request):
query = request.rel_url.query
if "path" not in query:
return web.Response(status=404)
return web.Response(status=204)
#NOTE: path always ends in `/`, so this is functionally an lstrip
path = os.path.abspath(strip_path(query["path"]))
@@ -145,10 +285,10 @@ async def get_path(request):
if item.is_dir():
valid_items.append(item.name + "/")
continue
if valid_extensions is None or item.name.split(".")[-1] in valid_extensions:
if valid_extensions is None or item.name.split(".")[-1].lower() in valid_extensions:
valid_items.append(item.name)
except OSError:
#Broken symlinks can throw a very unhelpful "Invalid argument"
pass
valid_items.sort(key=lambda f: os.stat(os.path.join(path,f)).st_mtime)
return web.json_response(valid_items)
+198 -11
View File
@@ -4,21 +4,28 @@ from typing import Iterable
import shutil
import subprocess
import re
import time
from collections.abc import Mapping
from typing import Union
import functools
import torch
from torch import Tensor
import server
from .logger import logger
import folder_paths
BIGMIN = -(2**53-1)
BIGMAX = (2**53-1)
DIMMAX = 8192
ENCODE_ARGS = ("utf-8", 'backslashreplace')
def ffmpeg_suitability(path):
try:
version = subprocess.run([path, "-version"], check=True,
capture_output=True).stdout.decode("utf-8")
capture_output=True).stdout.decode(*ENCODE_ARGS)
except:
return 0
score = 0
@@ -36,6 +43,23 @@ def ffmpeg_suitability(path):
score += int(copyright_year)
return score
class MultiInput(str):
def __new__(cls, string, allowed_types="*"):
res = super().__new__(cls, string)
res.allowed_types=allowed_types
return res
def __ne__(self, other):
if self.allowed_types == "*" or other == "*":
return False
return other not in self.allowed_types
imageOrLatent = MultiInput("IMAGE", ["IMAGE", "LATENT"])
floatOrInt = MultiInput("FLOAT", ["FLOAT", "INT"])
class ContainsAll(dict):
def __contains__(self, other):
return True
def __getitem__(self, key):
return super().get(key, (None, {}))
if "VHS_FORCE_FFMPEG_PATH" in os.environ:
ffmpeg_path = os.environ.get("VHS_FORCE_FFMPEG_PATH")
@@ -73,9 +97,34 @@ if gifski_path is None:
gifski_path = os.environ.get("JOV_GIFSKI", None)
if gifski_path is None:
gifski_path = shutil.which("gifski")
ytdl_path = os.environ.get("VHS_YTDL", None) or shutil.which('yt-dlp') \
or shutil.which('youtube-dl')
download_history = {}
def try_download_video(url):
if ytdl_path is None:
return None
if url in download_history:
return download_history[url]
os.makedirs(folder_paths.get_temp_directory(), exist_ok=True)
#Format information could be added to only download audio for Load Audio,
#but this gets hairy if same url is also used for video.
#Best to just always keep defaults
#dl_format = ['-f', 'ba'] if is_audio else []
try:
res = subprocess.run([ytdl_path, "--print", "after_move:filepath",
"-P", folder_paths.get_temp_directory(), url],
capture_output=True, check=True)
#strip newline
file = res.stdout.decode(*ENCODE_ARGS)[:-1]
except subprocess.CalledProcessError as e:
raise Exception("An error occurred in the yt-dl process:\n" \
+ e.stderr.decode(*ENCODE_ARGS))
file = None
download_history[url] = file
return file
def is_safe_path(path):
if "VHS_STRICT_PATHS" not in os.environ:
def is_safe_path(path, strict=False):
if "VHS_STRICT_PATHS" not in os.environ and not strict:
return True
basedir = os.path.abspath('.')
try:
@@ -119,8 +168,14 @@ prompt_queue = server.PromptServer.instance.prompt_queue
def requeue_workflow_unchecked():
"""Requeues the current workflow without checking for multiple requeues"""
currently_running = prompt_queue.currently_running
(_, _, prompt, extra_data, outputs_to_execute) = next(iter(currently_running.values()))
value = next(iter(currently_running.values()))
# Handle both old (5 values) and new (6 values) ComfyUI versions
if len(value) == 6:
(_, prompt_id, prompt, extra_data, outputs_to_execute, _) = value
else:
(_, prompt_id, prompt, extra_data, outputs_to_execute) = value
#Ensure batch_managers are marked stale
prompt = prompt.copy()
for uid in prompt:
@@ -132,13 +187,24 @@ def requeue_workflow_unchecked():
number = -server.PromptServer.instance.number
server.PromptServer.instance.number += 1
prompt_id = str(server.uuid.uuid4())
prompt_queue.put((number, prompt_id, prompt, extra_data, outputs_to_execute))
# Put back with 6 elements to match what ComfyUI expects
sensitive = value[5] if len(value) > 5 else {}
prompt_queue.put((number, prompt_id, prompt, extra_data, outputs_to_execute, sensitive))
requeue_guard = [None, 0, 0, {}]
def requeue_workflow(requeue_required=(-1,True)):
assert(len(prompt_queue.currently_running) == 1)
global requeue_guard
(run_number, _, prompt, _, _) = next(iter(prompt_queue.currently_running.values()))
value = next(iter(prompt_queue.currently_running.values()))
# Handle both old (5 values) and new (6 values) ComfyUI versions
if len(value) == 6:
(run_number, _, prompt, extra_data, outputs_to_execute, _) = value
else:
(run_number, _, prompt, extra_data, outputs_to_execute) = value
if requeue_guard[0] != run_number:
#Calculate a count of how many outputs are managed by a batch manager
managed_outputs=0
@@ -166,10 +232,10 @@ def get_audio(file, start_time=0, duration=0):
res = subprocess.run(args + ["-f", "f32le", "-"],
capture_output=True, check=True)
audio = torch.frombuffer(bytearray(res.stdout), dtype=torch.float32)
match = re.search(', (\\d+) Hz, (\\w+), ',res.stderr.decode(*ENCODE_ARGS))
except subprocess.CalledProcessError as e:
logger.warning(f"Failed to extract audio from: {file}")
audio = torch.zeros(1,2)
match = re.search(', (\\d+) Hz, (\\w+), ',res.stderr.decode('utf-8'))
raise Exception(f"VHS failed to extract audio from {file}:\n" \
+ e.stderr.decode(*ENCODE_ARGS))
if match:
ar = int(match.group(1))
#NOTE: Just throwing an error for other channel types right now
@@ -199,7 +265,7 @@ class LazyAudioMap(Mapping):
if self._dict is None:
self._dict = get_audio(self.file, self.start_time, self.duration)
return len(self._dict)
def lazy_get_audio(file, start_time=0, duration=0):
def lazy_get_audio(file, start_time=0, duration=0, **kwargs):
return LazyAudioMap(file, start_time, duration)
def is_url(url):
@@ -240,6 +306,8 @@ def hash_path(path):
return "input"
if is_url(path):
return "url"
if not os.path.isfile(path):
return "DNE"
return calculate_file_hash(strip_path(path))
@@ -254,3 +322,122 @@ def validate_path(path, allow_none=False, allow_url=True):
if not os.path.isfile(strip_path(path)):
return "Invalid file path: {}".format(path)
return is_safe_path(path)
def validate_index(index: int, length: int=0, is_range: bool=False, allow_negative=False, allow_missing=False) -> int:
# if part of range, do nothing
if is_range:
return index
# otherwise, validate index
# validate not out of range - only when latent_count is passed in
if length > 0 and index > length-1 and not allow_missing:
raise IndexError(f"Index '{index}' out of range for {length} item(s).")
# if negative, validate not out of range
if index < 0:
if not allow_negative:
raise IndexError(f"Negative indeces not allowed, but was '{index}'.")
conv_index = length+index
if conv_index < 0 and not allow_missing:
raise IndexError(f"Index '{index}', converted to '{conv_index}' out of range for {length} item(s).")
index = conv_index
return index
def convert_to_index_int(raw_index: str, length: int=0, is_range: bool=False, allow_negative=False, allow_missing=False) -> int:
try:
return validate_index(int(raw_index), length=length, is_range=is_range, allow_negative=allow_negative, allow_missing=allow_missing)
except ValueError as e:
raise ValueError(f"Index '{raw_index}' must be an integer.", e)
def convert_str_to_indexes(indexes_str: str, length: int=0, allow_missing=False) -> list[int]:
if not indexes_str:
return []
int_indexes = list(range(0, length))
allow_negative = length > 0
chosen_indexes = []
# parse string - allow positive ints, negative ints, and ranges separated by ':'
groups = indexes_str.split(",")
groups = [g.strip() for g in groups]
for g in groups:
# parse range of indeces (e.g. 2:16)
if ':' in g:
index_range = g.split(":", 2)
index_range = [r.strip() for r in index_range]
start_index = index_range[0]
if len(start_index) > 0:
start_index = convert_to_index_int(start_index, length=length, is_range=True, allow_negative=allow_negative, allow_missing=allow_missing)
else:
start_index = 0
end_index = index_range[1]
if len(end_index) > 0:
end_index = convert_to_index_int(end_index, length=length, is_range=True, allow_negative=allow_negative, allow_missing=allow_missing)
else:
end_index = length
# support step as well, to allow things like reversing, every-other, etc.
step = 1
if len(index_range) > 2:
step = index_range[2]
if len(step) > 0:
step = convert_to_index_int(step, length=length, is_range=True, allow_negative=True, allow_missing=True)
else:
step = 1
# if latents were passed in, base indeces on known latent count
if len(int_indexes) > 0:
chosen_indexes.extend(int_indexes[start_index:end_index][::step])
# otherwise, assume indeces are valid
else:
chosen_indexes.extend(list(range(start_index, end_index, step)))
# parse individual indeces
else:
chosen_indexes.append(convert_to_index_int(g, length=length, allow_negative=allow_negative, allow_missing=allow_missing))
return chosen_indexes
def select_indexes(input_obj: Union[Tensor, list], idxs: list):
if type(input_obj) == Tensor:
return input_obj[idxs]
else:
return [input_obj[i] for i in idxs]
def merge_filter_args(args, ftype="-vf"):
#TODO This doesn't account for filter_complex
#Will likely need to convert all filters to filter complex in the future
#But that requires source/output deduplication
try:
start_index = args.index(ftype)+1
index = start_index
while True:
index = args.index(ftype, index)
args[start_index] += ',' + args[index+1]
args.pop(index)
args.pop(index)
except ValueError:
pass
def select_indexes_from_str(input_obj: Union[Tensor, list], indexes: str, err_if_missing=True, err_if_empty=True):
real_idxs = convert_str_to_indexes(indexes, len(input_obj), allow_missing=not err_if_missing)
if err_if_empty and len(real_idxs) == 0:
raise Exception(f"Nothing was selected based on indexes found in '{indexes}'.")
return select_indexes(input_obj, real_idxs)
def hook(obj, attr):
def dec(f):
f = functools.update_wrapper(f, getattr(obj,attr))
setattr(obj,attr,f)
return f
return dec
def cached(duration):
def dec(f):
cached_ret = None
cache_time = 0
def cached_func():
nonlocal cache_time, cached_ret
if time.time() > cache_time + duration or cached_ret is None:
cache_time = time.time()
cached_ret = f()
return cached_ret
return cached_func
return dec
+1715 -396
View File
File diff suppressed because it is too large Load Diff
+15 -16
View File
@@ -11,7 +11,7 @@ function getVideoMetadata(file) {
let decoder = new TextDecoder();
// Check for known valid magic strings
if (dataView.getUint32(0) == 0x1A45DFA3) {
//webm
//webm/mkv (both use EBML/Matroska format)
//see http://wiki.webmproject.org/webm-metadata/global-metadata
//and https://www.matroska.org/technical/elements.html
//contrary to specs, tag seems consistently at start
@@ -31,7 +31,7 @@ function getVideoMetadata(file) {
if (n_octets < 4) {//250MB sanity cutoff
let length = (vint >> (8*(4-n_octets))) & ~(1 << (7*n_octets));
const content = decoder.decode(videoData.slice(offset+2+n_octets, offset+2+n_octets+length));
const json = JSON.parse(content);
let json = JSON.parse(content);
r(json);
return;
}
@@ -61,10 +61,9 @@ function getVideoMetadata(file) {
}
} else {
console.error("Unknown magic: " + dataView.getUint32(0))
r();
return;
}
r();
return;
};
reader.readAsArrayBuffer(file);
@@ -77,26 +76,26 @@ function isVideoFile(file) {
if (file?.name?.endsWith(".mp4")) {
return true;
}
if (file?.name?.endsWith(".mkv")) {
return true;
}
return false;
}
let originalHandleFile = app.handleFile;
app.handleFile = handleFile;
let fileInput = document.getElementById("comfy-file-input")
//hijack comfy-file-input to allow webm/mp4/mkv
fileInput.accept += ",video/webm,video/mp4,video/x-matroska";
async function handleFile(file) {
if (file?.type?.startsWith("video/") || isVideoFile(file)) {
const videoInfo = await getVideoMetadata(file);
if (videoInfo) {
if (videoInfo.workflow) {
app.loadGraphData(videoInfo.workflow);
}
//Potentially check for/parse A1111 metadata here.
if (videoInfo?.workflow) {
await app.loadGraphData(videoInfo.workflow);
return
}
} else {
return await originalHandleFile.apply(this, arguments);
}
return await originalHandleFile.apply(this, arguments);
}
//hijack comfy-file-input to allow webm/mp4
document.getElementById("comfy-file-input").accept += ",video/webm,video/mp4";