Files
olivv-cs-ComfyUI-FunPack/modules/output/save/__init__.py
T
digital-garbageandClaude Sonnet 5.5 f5050a4574 Shorter in-app hints: what a setting does, where to start, what it needs
Long hints for 33 settings are cut to one or two sentences each, and the
Taste steering strength and blocks hints now give a starting value and
recommended blocks.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 13:26:17 +02:00

143 lines
7.3 KiB
Python

"""Save the finished video: MP4 (H.264), on the GPU's encoder when there is one, else on every CPU core.
Core's SaveVideo encodes on one CPU thread; on a rental that was most of the time after decoding.
"""
from . import watermark
from .nodes import FunUISaveVideo
ID = "output_save"
TITLE = "Save video"
STAGE = "post"
CATEGORY = "post"
MOUNT = "settings.general"
STATUS = "proven"
NODES = [FunUISaveVideo]
# Engine > Post. Every one is off (or neutral) by default; the node reads them from the pipeline's settings object.
SETTINGS = {
"smoothing": {
"type": "enum", "default": "off",
"options": [{"value": "off", "label": "Off"}, {"value": "light", "label": "Light"}, {"value": "strong", "label": "Strong"}],
"label": "Smooth jagged edges",
"hint": "Smooths jagged edges by softening only high-contrast ones. Slower on a CPU for 4K clips.",
},
"auto_contrast": {
"type": "bool", "default": False,
"label": "Auto contrast",
"hint": "Stretches the darkest and brightest levels toward black and white, by at most 15% each. Measured once over the clip, so it never flickers.",
},
"auto_brightness": {
"type": "bool", "default": False,
"label": "Auto brightness",
"hint": "Moves the midtones toward a middle level, within a mild range. Measured once over the clip.",
},
"saturation": {
"type": "float", "default": 1.0, "min": 0.5, "max": 1.5, "step": 0.05, "ui": "slider",
"label": "Colour strength",
"hint": "1 leaves the colours as they are; below 1 mutes them, above 1 makes them stronger.",
},
"watermark": {
"type": "enum", "default": "off",
"options": [{"value": "off", "label": "Off"}, {"value": "text", "label": "Text"}, {"value": "picture", "label": "Picture"}],
"label": "Watermark",
"hint": "Adds a text or a picture from the media bin in one corner of the finished video. It appears in the saved file only, not in the Preview.",
},
"watermark_text": {
"type": "text", "default": "", "label": "Watermark text", "when": {"watermark": "text"},
"hint": "The words to show. They are scaled to the width below, whatever their length.",
},
"watermark_colour": {
"type": "color", "default": "#ffffff", "label": "Text colour", "when": {"watermark": "text"},
"hint": "The colour of the text.",
},
"watermark_picture": {
"type": "enum", "keep_missing": True, "default": "", "options": [{"value": "", "label": "None picked"}], "label": "Watermark picture", "when": {"watermark": "picture"},
"hint": "A picture from the media bin. A transparent PNG keeps its transparency.",
},
"watermark_corner": {
"type": "enum", "default": "bottom-right", "label": "Corner", "when": {"watermark": ["text", "picture"]},
"options": [{"value": "top-left", "label": "Top left"}, {"value": "top-right", "label": "Top right"},
{"value": "bottom-left", "label": "Bottom left"}, {"value": "bottom-right", "label": "Bottom right"}],
"hint": "Which corner of the video the mark sits in.",
},
"watermark_opacity": {
"type": "float", "default": 0.6, "min": 0.05, "max": 1.0, "step": 0.05, "ui": "slider", "label": "Opacity", "when": {"watermark": ["text", "picture"]},
"hint": "1 is solid, lower lets the video show through.",
},
"watermark_size": {
"type": "float", "default": 18.0, "min": 5.0, "max": 60.0, "step": 1.0, "ui": "slider", "label": "Width", "unit": "% of the video's width",
"when": {"watermark": ["text", "picture"]},
"hint": "How wide the mark is. It never takes more than half the video's height.",
},
"watermark_margin": {
"type": "float", "default": 2.0, "min": 0.0, "max": 20.0, "step": 0.5, "ui": "slider", "label": "Distance from the edge", "unit": "% of the video's width",
"when": {"watermark": ["text", "picture"]},
"hint": "How far the mark stays from the edges. The outline is kept inside it.",
},
"watermark_outline": {
"type": "float", "default": 0.0, "min": 0.0, "max": 20.0, "step": 0.5, "ui": "slider", "label": "Outline size", "unit": "% of the mark's height",
"when": {"watermark": ["text", "picture"]},
"hint": "0 draws no outline. For a picture the outline follows its shape, so it suits a transparent PNG.",
},
"watermark_outline_colour": {
"type": "color", "default": "#000000", "label": "Outline colour", "when": {"watermark": ["text", "picture"]},
"hint": "The colour of the outline.",
},
"watermark_outline_opacity": {
"type": "float", "default": 1.0, "min": 0.05, "max": 1.0, "step": 0.05, "ui": "slider", "label": "Outline opacity", "when": {"watermark": ["text", "picture"]},
"hint": "1 is solid, lower lets the video show through the outline. Only used when the outline size is above 0.",
},
}
PROVIDES_OPTIONS = lambda: {"watermark_picture": watermark.picture_options()}
def _link(v):
return isinstance(v, (list, tuple)) and len(v) == 2 and isinstance(v[0], str) and isinstance(v[1], int)
def upgrade(slots, outputs):
"""Pipelines saved before this node hold core's CreateVideo -> SaveVideo: one FunUI Save Video instead.
A SaveVideo left with no feed takes the one picture source nothing else reads (its sound too).
`outputs(node)` -> that node's output types. -> the slots, changed or not."""
slots = [dict(s) for s in slots]
by_id = {s["id"]: s for s in slots}
read = {v[0] for s in slots for v in (s.get("inputs") or {}).values() if _link(v)}
drop = set()
for s in slots:
if s["node"] != "SaveVideo":
continue
feed = (s.get("inputs") or {}).get("video")
src = by_id.get(feed[0]) if _link(feed) else None
new = {"filename_prefix": (s.get("inputs") or {}).get("filename_prefix", "FunUI")}
roles = s.get("roles")
if src is not None and src["node"] == "CreateVideo":
new.update({k: v for k, v in (src.get("inputs") or {}).items() if k in ("images", "audio", "fps")})
roles = src.get("roles") or roles
if sum(1 for o in slots for v in (o.get("inputs") or {}).values() if _link(v) and v[0] == src["id"]) == 1:
drop.add(src["id"])
elif src is None:
loose = [o for o in slots if o["id"] not in read and "IMAGE" in outputs(o["node"])]
if len(loose) != 1:
continue
kinds = outputs(loose[0]["node"])
new["images"] = [loose[0]["id"], kinds.index("IMAGE")]
if "AUDIO" in kinds:
new["audio"] = [loose[0]["id"], kinds.index("AUDIO")]
else:
continue
s.update(node="FunUISaveVideo", inputs=new)
if roles:
s["roles"] = roles
# Engine > Post settings reach the node through the pipeline's settings object; a pipeline saved before that is linked to it.
sink = next((s["id"] for s in slots if s["node"] == "FunUIModifierSettings"), None)
for s in slots:
if sink and s["node"] == "FunUISaveVideo" and "settings" not in (s.get("inputs") or {}):
s["inputs"] = {**(s.get("inputs") or {}), "settings": [sink, 0]}
return [s for s in slots if s["id"] not in drop]
PROVIDES = {"upgrade_slots": upgrade, "setting_options": PROVIDES_OPTIONS}