timeline: adjustment layers and first-class transitions
Adjustment layers (docs/zh/plans/adjustment-layers-and-transitions.md): a new timeline block type whose effect chain grades the composite of every video track below it, over its own range (spanning clips or a slice of one). The graph path flushes the lower tracks at the block's track boundary and sweeps the composite through the chain via a transient texture-source node; the montage path mirrors it with AdjustmentSpan tickets (wire-compatible), so worker previews and exports agree. An empty-area context menu creates one; the block trims/moves/deletes like a clip, with undo everywhere. Transitions: seam blocks come alive - cross dissolve/fade/wipe/slide evaluate both neighbors through the graph path with progress from the transition's own range (never the whole clip). Ctrl+Shift+D or the clip menu inserts a default transition; the gpui wedges render and drag to resize offsets undoably, and TransitionRemoveCommand now restores offsets and edges on undo. The transitionfx node form runs the same shaders on an adjustment layer with progress_in auto-filled from the layer's span (explicit value wins). Also: every built-in effect name and parameter name is now translatable (360 node.* keys per locale, zh-CN fully translated, two coverage tests guard future gaps); the new nodes register in nodes/mod.rs with the factory smoke table updated; textfootage and adjustment-layer i18n keys included.
This commit is contained in:
@@ -171,6 +171,380 @@
|
||||
"multicam.switch_9": "Zu Kamera 9 wechseln"
|
||||
"effect_library.hint": "Doppelklicken, um zum ausgewählten Clip hinzuzufügen"
|
||||
"effect_library.group.builtin": "Integriert"
|
||||
"effect_library.group.color": "Color"
|
||||
"effect_library.group.filter": "Filter"
|
||||
"effect_library.group.distort": "Distort"
|
||||
"effect_library.group.keying": "Keying"
|
||||
"effect_library.group.generator": "Generator"
|
||||
"effect_library.group.math": "Math"
|
||||
"effect_library.group.time": "Time"
|
||||
"effect_library.group.channel": "Channel"
|
||||
"effect_library.group.transition": "Transition"
|
||||
"effect_library.group.general": "General"
|
||||
# Built-in effect (node) and effect-parameter (input) names. The effect
|
||||
# library and the inspector build these keys at runtime as
|
||||
# `node.<type-id suffix>.name` and `node.<type-id suffix>.input.<input id>`.
|
||||
"node.polygon.name": "Polygon"
|
||||
"node.polygon.input.base_in": "Base"
|
||||
"node.polygon.input.points_in": "Points"
|
||||
"node.polygon.input.color_in": "Color"
|
||||
"node.ortho.name": "Orthographic Matrix"
|
||||
"node.ortho.input.pos_in": "Position"
|
||||
"node.ortho.input.rot_in": "Rotation"
|
||||
"node.ortho.input.scale_in": "Scale"
|
||||
"node.ortho.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.ortho.input.anchor_in": "Anchor Point"
|
||||
"node.transform.name": "Transform"
|
||||
"node.transform.input.tex_in": "Texture"
|
||||
"node.transform.input.parent_in": "Parent"
|
||||
"node.transform.input.autoscale_in": "Auto-Scale"
|
||||
"node.transform.input.interpolation_in": "Interpolation"
|
||||
"node.transform.input.pos_in": "Position"
|
||||
"node.transform.input.rot_in": "Rotation"
|
||||
"node.transform.input.scale_in": "Scale"
|
||||
"node.transform.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.transform.input.anchor_in": "Anchor Point"
|
||||
"node.volume.name": "Volume"
|
||||
"node.volume.input.samples_in": "Samples"
|
||||
"node.volume.input.volume_in": "Volume"
|
||||
"node.pan.name": "Pan"
|
||||
"node.pan.input.samples_in": "Samples"
|
||||
"node.pan.input.panning_in": "Pan"
|
||||
"node.math.name": "Math"
|
||||
"node.math.input.method_in": "Method"
|
||||
"node.math.input.param_a_in": "Value"
|
||||
"node.math.input.param_b_in": "Value"
|
||||
"node.time.name": "Time"
|
||||
"node.time.input.enabled_in": "Enabled"
|
||||
"node.trigonometry.name": "Trigonometry"
|
||||
"node.trigonometry.input.method_in": "Method"
|
||||
"node.trigonometry.input.x_in": "Value"
|
||||
"node.blur.name": "Blur"
|
||||
"node.blur.input.tex_in": "Input"
|
||||
"node.blur.input.method_in": "Method"
|
||||
"node.blur.input.radius_in": "Radius"
|
||||
"node.blur.input.horiz_in": "Horizontal"
|
||||
"node.blur.input.vert_in": "Vertical"
|
||||
"node.blur.input.directional_degrees_in": "Direction"
|
||||
"node.blur.input.radial_center_in": "Center"
|
||||
"node.blur.input.repeat_edge_pixels_in": "Repeat Edge Pixels"
|
||||
"node.solidgenerator.name": "Solid"
|
||||
"node.solidgenerator.input.color_in": "Color"
|
||||
"node.merge.name": "Merge"
|
||||
"node.merge.input.base_in": "Base"
|
||||
"node.merge.input.blend_in": "Blend"
|
||||
"node.stroke.name": "Stroke"
|
||||
"node.stroke.input.tex_in": "Input"
|
||||
"node.stroke.input.color_in": "Color"
|
||||
"node.stroke.input.radius_in": "Radius"
|
||||
"node.stroke.input.opacity_in": "Opacity"
|
||||
"node.stroke.input.inner_in": "Inner"
|
||||
"node.textgenerator.name": "Text (Legacy)"
|
||||
"node.textgenerator.input.text_in": "Text"
|
||||
"node.textgenerator.input.html_in": "Enable HTML"
|
||||
"node.textgenerator.input.color_in": "Color"
|
||||
"node.textgenerator.input.valign_in": "Vertical Align"
|
||||
"node.textgenerator.input.font_in": "Font"
|
||||
"node.textgenerator.input.font_size_in": "Font Size"
|
||||
"node.text2.name": "Text (Legacy)"
|
||||
"node.text2.input.base_in": "Base"
|
||||
"node.text2.input.pos_in": "Position"
|
||||
"node.text2.input.size_in": "Size"
|
||||
"node.text2.input.color_in": "Color"
|
||||
"node.text2.input.text_in": "Text"
|
||||
"node.text2.input.html_in": "Enable HTML"
|
||||
"node.text2.input.valign_in": "Vertical Align"
|
||||
"node.text2.input.font_in": "Font"
|
||||
"node.text2.input.font_size_in": "Font Size"
|
||||
"node.text3.name": "Text"
|
||||
"node.text3.input.base_in": "Base"
|
||||
"node.text3.input.pos_in": "Position"
|
||||
"node.text3.input.size_in": "Size"
|
||||
"node.text3.input.plain_text_in": "Text"
|
||||
"node.text3.input.text_in": "Text"
|
||||
"node.text3.input.font_family_in": "Font Family"
|
||||
"node.text3.input.font_size_in": "Font Size"
|
||||
"node.text3.input.outline_enabled_in": "Outline"
|
||||
"node.text3.input.outline_color_in": "Outline Color"
|
||||
"node.text3.input.outline_width_in": "Outline Width"
|
||||
"node.text3.input.glow_enabled_in": "Glow"
|
||||
"node.text3.input.glow_color_in": "Glow Color"
|
||||
"node.text3.input.glow_radius_in": "Glow Radius"
|
||||
"node.text3.input.color_in": "Color"
|
||||
"node.text3.input.valign_in": "Vertical Alignment"
|
||||
"node.text3.input.args_in": "Arguments"
|
||||
"node.mosaicfilter.name": "Mosaic"
|
||||
"node.mosaicfilter.input.tex_in": "Texture"
|
||||
"node.mosaicfilter.input.horiz_in": "Horizontal"
|
||||
"node.mosaicfilter.input.vert_in": "Vertical"
|
||||
"node.crop.name": "Crop"
|
||||
"node.crop.input.tex_in": "Texture"
|
||||
"node.crop.input.left_in": "Left"
|
||||
"node.crop.input.top_in": "Top"
|
||||
"node.crop.input.right_in": "Right"
|
||||
"node.crop.input.bottom_in": "Bottom"
|
||||
"node.crop.input.feather_in": "Feather"
|
||||
"node.value.name": "Value"
|
||||
"node.value.input.type_in": "Type"
|
||||
"node.value.input.value_in": "Value"
|
||||
"node.timeremap.name": "Time Remap"
|
||||
"node.timeremap.input.time_in": "Time"
|
||||
"node.timeremap.input.input_in": "Input"
|
||||
"node.shape.name": "Shape"
|
||||
"node.shape.input.type_in": "Type"
|
||||
"node.shape.input.base_in": "Base"
|
||||
"node.shape.input.pos_in": "Position"
|
||||
"node.shape.input.size_in": "Size"
|
||||
"node.shape.input.color_in": "Color"
|
||||
"node.shape.input.radius_in": "Radius"
|
||||
"node.colordifferencekey.name": "Color Difference Key"
|
||||
"node.colordifferencekey.input.tex_in": "Input"
|
||||
"node.colordifferencekey.input.garbage_in": "Garbage Matte"
|
||||
"node.colordifferencekey.input.core_in": "Core Matte"
|
||||
"node.colordifferencekey.input.color_in": "Key Color"
|
||||
"node.colordifferencekey.input.highlights_in": "Highlights"
|
||||
"node.colordifferencekey.input.shadows_in": "Shadows"
|
||||
"node.colordifferencekey.input.mask_only_in": "Show Mask Only"
|
||||
"node.despill.name": "Despill"
|
||||
"node.despill.input.tex_in": "Input"
|
||||
"node.despill.input.color_in": "Key Color"
|
||||
"node.despill.input.method_in": "Method"
|
||||
"node.despill.input.preserve_luminance_input": "Preserve Luminance"
|
||||
"node.group.name": "Group"
|
||||
"node.opacity.name": "Opacity"
|
||||
"node.opacity.input.tex_in": "Texture"
|
||||
"node.opacity.input.opacity_in": "Opacity"
|
||||
"node.flip.name": "Flip"
|
||||
"node.flip.input.tex_in": "Input"
|
||||
"node.flip.input.horiz_in": "Horizontal"
|
||||
"node.flip.input.vert_in": "Vertical"
|
||||
"node.noise.name": "Noise"
|
||||
"node.noise.input.base_in": "Base"
|
||||
"node.noise.input.strength_in": "Strength"
|
||||
"node.noise.input.color_in": "Color"
|
||||
"node.timeoffset.name": "Time Offset"
|
||||
"node.timeoffset.input.time_in": "Time"
|
||||
"node.timeoffset.input.input_in": "Input"
|
||||
"node.transition.name": "Transition"
|
||||
"node.transition.input.tex_in": "From"
|
||||
"node.transition.input.blend_in": "To"
|
||||
"node.transition.input.progress_in": "Progress"
|
||||
"node.transition.input.type_in": "Type"
|
||||
"node.transitionfx.name": "Transition FX"
|
||||
"node.transitionfx.input.tex_in": "From"
|
||||
"node.transitionfx.input.blend_in": "To"
|
||||
"node.transitionfx.input.progress_in": "Progress"
|
||||
"node.transitionfx.input.color_in": "Color"
|
||||
"node.transitionfx.input.direction_in": "Direction"
|
||||
"node.transitionfx.input.type_in": "Type"
|
||||
"node.cornerpin.name": "Corner Pin"
|
||||
"node.cornerpin.input.tex_in": "Texture"
|
||||
"node.cornerpin.input.perspective_in": "Perspective"
|
||||
"node.cornerpin.input.top_left_in": "Top Left"
|
||||
"node.cornerpin.input.top_right_in": "Top Right"
|
||||
"node.cornerpin.input.bottom_right_in": "Bottom Right"
|
||||
"node.cornerpin.input.bottom_left_in": "Bottom Left"
|
||||
"node.displaytransform.name": "Display Transform"
|
||||
"node.displaytransform.input.tex_in": "Input"
|
||||
"node.displaytransform.input.display_in": "Display"
|
||||
"node.displaytransform.input.view_in": "View"
|
||||
"node.displaytransform.input.dir_in": "Direction"
|
||||
"node.ociogradingtransformlinear.name": "OCIO Color Grading (Linear)"
|
||||
"node.ociogradingtransformlinear.input.tex_in": "Input"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_contrast": "Contrast"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_offset": "Offset"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_exposure": "Exposure"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_saturation": "Saturation"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_pivot": "Pivot"
|
||||
"node.ociogradingtransformlinear.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampWhite": "White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.name": "OCIO Color Grading (Log)"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.tex_in": "Input"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_brightness": "Lift"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_contrast": "Gain"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_gamma": "Gamma"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_saturation": "Saturation"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_pivot": "Pivot"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampWhite": "White Clamp"
|
||||
"node.whitebalance.name": "White Balance"
|
||||
"node.whitebalance.input.tex_in": "Input"
|
||||
"node.whitebalance.input.temperature_in": "Temperature (K)"
|
||||
"node.whitebalance.input.tint_in": "Tint"
|
||||
"node.ociolut.name": "OCIO LUT"
|
||||
"node.ociolut.input.tex_in": "Input"
|
||||
"node.ociolut.input.lut_file_in": "LUT File"
|
||||
"node.ociolut.input.lut_dir_in": "Direction"
|
||||
"node.threewaycolor.name": "Three-Way Color"
|
||||
"node.threewaycolor.input.tex_in": "Input"
|
||||
"node.threewaycolor.input.shadows_color_in": "Shadows"
|
||||
"node.threewaycolor.input.midtones_color_in": "Midtones"
|
||||
"node.threewaycolor.input.highlights_color_in": "Highlights"
|
||||
"node.threewaycolor.input.shadows_amount_in": "Shadows Amount"
|
||||
"node.threewaycolor.input.midtones_amount_in": "Midtones Amount"
|
||||
"node.threewaycolor.input.highlights_amount_in": "Highlights Amount"
|
||||
"node.chromakey.name": "Chroma Key"
|
||||
"node.chromakey.input.tex_in": "Input"
|
||||
"node.chromakey.input.color_key": "Key Color"
|
||||
"node.chromakey.input.lower_tolerance_in": "Lower Tolerance"
|
||||
"node.chromakey.input.upper_tolerance_in": "Upper Tolerance"
|
||||
"node.chromakey.input.garbage_in": "Garbage Matte"
|
||||
"node.chromakey.input.core_in": "Core Matte"
|
||||
"node.chromakey.input.highlights_in": "Highlights"
|
||||
"node.chromakey.input.shadows_in": "Shadows"
|
||||
"node.chromakey.input.invert_in": "Invert Mask"
|
||||
"node.chromakey.input.mask_only_in": "Show Mask Only"
|
||||
"node.mask.name": "Mask"
|
||||
"node.mask.input.base_in": "Texture"
|
||||
"node.mask.input.points_in": "Points"
|
||||
"node.mask.input.color_in": "Color"
|
||||
"node.mask.input.invert_in": "Invert"
|
||||
"node.mask.input.feather_in": "Feather"
|
||||
"node.dropshadow.name": "Drop Shadow"
|
||||
"node.dropshadow.input.tex_in": "Texture"
|
||||
"node.dropshadow.input.color_in": "Color"
|
||||
"node.dropshadow.input.distance_in": "Distance"
|
||||
"node.dropshadow.input.angle_in": "Angle"
|
||||
"node.dropshadow.input.radius_in": "Softness"
|
||||
"node.dropshadow.input.opacity_in": "Opacity"
|
||||
"node.dropshadow.input.fast_in": "Faster (Lower Quality)"
|
||||
"node.timeformat.name": "Time Format"
|
||||
"node.timeformat.input.time_in": "Time"
|
||||
"node.timeformat.input.format_in": "Format"
|
||||
"node.timeformat.input.localtime_in": "Interpret time as local time"
|
||||
"node.wave.name": "Wave"
|
||||
"node.wave.input.tex_in": "Input"
|
||||
"node.wave.input.frequency_in": "Frequency"
|
||||
"node.wave.input.intensity_in": "Intensity"
|
||||
"node.wave.input.evolution_in": "Evolution"
|
||||
"node.wave.input.vertical_in": "Direction"
|
||||
"node.tile.name": "Tile"
|
||||
"node.tile.input.tex_in": "Input"
|
||||
"node.tile.input.scale_in": "Scale"
|
||||
"node.tile.input.position_in": "Position"
|
||||
"node.tile.input.anchor_in": "Anchor"
|
||||
"node.tile.input.mirrorx_in": "Mirror Horizontally"
|
||||
"node.tile.input.mirrory_in": "Mirror Vertically"
|
||||
"node.swirl.name": "Swirl"
|
||||
"node.swirl.input.tex_in": "Input"
|
||||
"node.swirl.input.radius_in": "Radius"
|
||||
"node.swirl.input.angle_in": "Angle"
|
||||
"node.swirl.input.pos_in": "Position"
|
||||
"node.ripple.name": "Ripple"
|
||||
"node.ripple.input.tex_in": "Input"
|
||||
"node.ripple.input.evolution_in": "Evolution"
|
||||
"node.ripple.input.intensity_in": "Intensity"
|
||||
"node.ripple.input.frequency_in": "Frequency"
|
||||
"node.ripple.input.position_in": "Position"
|
||||
"node.ripple.input.stretch_in": "Stretch"
|
||||
"node.colormatrix.name": "Color Matrix"
|
||||
"node.colormatrix.input.tex_in": "Input"
|
||||
"node.colormatrix.input.m0": "R <- R"
|
||||
"node.colormatrix.input.m1": "R <- G"
|
||||
"node.colormatrix.input.m2": "R <- B"
|
||||
"node.colormatrix.input.m3": "R <- A"
|
||||
"node.colormatrix.input.m4": "G <- R"
|
||||
"node.colormatrix.input.m5": "G <- G"
|
||||
"node.colormatrix.input.m6": "G <- B"
|
||||
"node.colormatrix.input.m7": "G <- A"
|
||||
"node.colormatrix.input.m8": "B <- R"
|
||||
"node.colormatrix.input.m9": "B <- G"
|
||||
"node.colormatrix.input.m10": "B <- B"
|
||||
"node.colormatrix.input.m11": "B <- A"
|
||||
"node.colormatrix.input.m12": "A <- R"
|
||||
"node.colormatrix.input.m13": "A <- G"
|
||||
"node.colormatrix.input.m14": "A <- B"
|
||||
"node.colormatrix.input.m15": "A <- A"
|
||||
"node.dilate.name": "Dilate"
|
||||
"node.dilate.input.tex_in": "Input"
|
||||
"node.dilate.input.radius_in": "Radius"
|
||||
"node.edgedetect.name": "Edge Detect"
|
||||
"node.edgedetect.input.tex_in": "Input"
|
||||
"node.edgedetect.input.threshold_in": "Threshold"
|
||||
"node.erode.name": "Erode"
|
||||
"node.erode.input.tex_in": "Input"
|
||||
"node.erode.input.radius_in": "Radius"
|
||||
"node.colorcorrect.name": "Color Correct"
|
||||
"node.colorcorrect.input.tex_in": "Input"
|
||||
"node.colorcorrect.input.saturation_in": "Saturation"
|
||||
"node.colorcorrect.input.contrast_in": "Contrast"
|
||||
"node.colorcorrect.input.gamma_in": "Gamma"
|
||||
"node.colorcorrect.input.gain_in": "Gain"
|
||||
"node.colorcorrect.input.offset_in": "Offset"
|
||||
"node.gamma.name": "Gamma"
|
||||
"node.gamma.input.tex_in": "Input"
|
||||
"node.gamma.input.gamma_in": "Gamma"
|
||||
"node.saturation.name": "Saturation"
|
||||
"node.saturation.input.tex_in": "Input"
|
||||
"node.saturation.input.saturation_in": "Saturation"
|
||||
"node.invert.name": "Invert"
|
||||
"node.invert.input.tex_in": "Input"
|
||||
"node.invert.input.invert_r_in": "Invert Red"
|
||||
"node.invert.input.invert_g_in": "Invert Green"
|
||||
"node.invert.input.invert_b_in": "Invert Blue"
|
||||
"node.invert.input.invert_a_in": "Invert Alpha"
|
||||
"node.clamp.name": "Clamp"
|
||||
"node.clamp.input.tex_in": "Input"
|
||||
"node.clamp.input.min_in": "Min"
|
||||
"node.clamp.input.max_in": "Max"
|
||||
"node.grade.name": "Grade"
|
||||
"node.grade.input.tex_in": "Input"
|
||||
"node.grade.input.blackpoint_in": "Black Point"
|
||||
"node.grade.input.whitepoint_in": "White Point"
|
||||
"node.grade.input.black_in": "Black Output"
|
||||
"node.grade.input.white_in": "White Output"
|
||||
"node.grade.input.gamma_in": "Gamma"
|
||||
"node.dirblur.name": "Directional Blur"
|
||||
"node.dirblur.input.tex_in": "Input"
|
||||
"node.dirblur.input.amount_in": "Amount"
|
||||
"node.dirblur.input.angle_in": "Angle"
|
||||
"node.sharpen.name": "Sharpen"
|
||||
"node.sharpen.input.tex_in": "Input"
|
||||
"node.sharpen.input.amount_in": "Amount"
|
||||
"node.dissolve.name": "Dissolve"
|
||||
"node.dissolve.input.tex_in": "Input"
|
||||
"node.dissolve.input.blend_in": "Blend"
|
||||
"node.dissolve.input.mix_in": "Mix"
|
||||
"node.keymix.name": "KeyMix"
|
||||
"node.keymix.input.tex_in": "Input"
|
||||
"node.keymix.input.blend_in": "Blend"
|
||||
"node.keymix.input.mask_in": "Mask"
|
||||
"node.premult.name": "Premultiply"
|
||||
"node.premult.input.tex_in": "Input"
|
||||
"node.premult.input.premult_channel_in": "Channel"
|
||||
"node.unpremult.name": "Unpremultiply"
|
||||
"node.unpremult.input.tex_in": "Input"
|
||||
"node.unpremult.input.unpremult_channel_in": "Channel"
|
||||
"node.position.name": "Position"
|
||||
"node.position.input.tex_in": "Input"
|
||||
"node.position.input.offset_in": "Translate"
|
||||
"node.mirror.name": "Mirror"
|
||||
"node.mirror.input.tex_in": "Input"
|
||||
"node.mirror.input.horizontal_in": "Horizontal (flop)"
|
||||
"node.mirror.input.vertical_in": "Vertical (flip)"
|
||||
"node.checkerboard.name": "Checkerboard"
|
||||
"node.checkerboard.input.base_in": "Base"
|
||||
"node.checkerboard.input.size_in": "Box Size"
|
||||
"node.checkerboard.input.color1_in": "Color 1"
|
||||
"node.checkerboard.input.color2_in": "Color 2"
|
||||
"node.colorbars.name": "Color Bars"
|
||||
"node.colorbars.input.base_in": "Base"
|
||||
"node.colorbars.input.standard_in": "Standard"
|
||||
"node.ramp.name": "Ramp"
|
||||
"node.ramp.input.base_in": "Base"
|
||||
"node.ramp.input.point0_in": "Point 0"
|
||||
"node.ramp.input.point1_in": "Point 1"
|
||||
"node.ramp.input.color0_in": "Color 0"
|
||||
"node.ramp.input.color1_in": "Color 1"
|
||||
"node.multicam.name": "Multi-Cam"
|
||||
"node.multicam.input.current_in": "Current"
|
||||
"node.multicam.input.sources_in": "Sources"
|
||||
"node.multicam.input.sequence_in": "Sequence"
|
||||
"node.multicam.input.sequence_type_in": "Sequence Type"
|
||||
"status.ready": "Bereit"
|
||||
"status.cache": "Cache: Aktiviert"
|
||||
"status.proxy.on": "Proxy: Ein"
|
||||
@@ -481,6 +855,8 @@
|
||||
"node.category.group": "Gruppe"
|
||||
"project.context.new": "Neu"
|
||||
"project.new_sequence": "Neue Sequenz"
|
||||
"project.add_text_footage": "Add Text Footage"
|
||||
"timeline.context.add_adjustment_layer": "Add Adjustment Layer"
|
||||
"project.context.reveal_in_finder": "Im Finder anzeigen"
|
||||
"project.context.replace_footage": "Material ersetzen"
|
||||
"project.context.rename": "Umbenennen"
|
||||
|
||||
@@ -169,6 +169,380 @@
|
||||
"multicam.switch_9": "Switch to Camera 9"
|
||||
"effect_library.hint": "Double-click to add to the selected clip"
|
||||
"effect_library.group.builtin": "Built-in"
|
||||
"effect_library.group.color": "Color"
|
||||
"effect_library.group.filter": "Filter"
|
||||
"effect_library.group.distort": "Distort"
|
||||
"effect_library.group.keying": "Keying"
|
||||
"effect_library.group.generator": "Generator"
|
||||
"effect_library.group.math": "Math"
|
||||
"effect_library.group.time": "Time"
|
||||
"effect_library.group.channel": "Channel"
|
||||
"effect_library.group.transition": "Transition"
|
||||
"effect_library.group.general": "General"
|
||||
# Built-in effect (node) and effect-parameter (input) names. The effect
|
||||
# library and the inspector build these keys at runtime as
|
||||
# `node.<type-id suffix>.name` and `node.<type-id suffix>.input.<input id>`.
|
||||
"node.polygon.name": "Polygon"
|
||||
"node.polygon.input.base_in": "Base"
|
||||
"node.polygon.input.points_in": "Points"
|
||||
"node.polygon.input.color_in": "Color"
|
||||
"node.ortho.name": "Orthographic Matrix"
|
||||
"node.ortho.input.pos_in": "Position"
|
||||
"node.ortho.input.rot_in": "Rotation"
|
||||
"node.ortho.input.scale_in": "Scale"
|
||||
"node.ortho.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.ortho.input.anchor_in": "Anchor Point"
|
||||
"node.transform.name": "Transform"
|
||||
"node.transform.input.tex_in": "Texture"
|
||||
"node.transform.input.parent_in": "Parent"
|
||||
"node.transform.input.autoscale_in": "Auto-Scale"
|
||||
"node.transform.input.interpolation_in": "Interpolation"
|
||||
"node.transform.input.pos_in": "Position"
|
||||
"node.transform.input.rot_in": "Rotation"
|
||||
"node.transform.input.scale_in": "Scale"
|
||||
"node.transform.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.transform.input.anchor_in": "Anchor Point"
|
||||
"node.volume.name": "Volume"
|
||||
"node.volume.input.samples_in": "Samples"
|
||||
"node.volume.input.volume_in": "Volume"
|
||||
"node.pan.name": "Pan"
|
||||
"node.pan.input.samples_in": "Samples"
|
||||
"node.pan.input.panning_in": "Pan"
|
||||
"node.math.name": "Math"
|
||||
"node.math.input.method_in": "Method"
|
||||
"node.math.input.param_a_in": "Value"
|
||||
"node.math.input.param_b_in": "Value"
|
||||
"node.time.name": "Time"
|
||||
"node.time.input.enabled_in": "Enabled"
|
||||
"node.trigonometry.name": "Trigonometry"
|
||||
"node.trigonometry.input.method_in": "Method"
|
||||
"node.trigonometry.input.x_in": "Value"
|
||||
"node.blur.name": "Blur"
|
||||
"node.blur.input.tex_in": "Input"
|
||||
"node.blur.input.method_in": "Method"
|
||||
"node.blur.input.radius_in": "Radius"
|
||||
"node.blur.input.horiz_in": "Horizontal"
|
||||
"node.blur.input.vert_in": "Vertical"
|
||||
"node.blur.input.directional_degrees_in": "Direction"
|
||||
"node.blur.input.radial_center_in": "Center"
|
||||
"node.blur.input.repeat_edge_pixels_in": "Repeat Edge Pixels"
|
||||
"node.solidgenerator.name": "Solid"
|
||||
"node.solidgenerator.input.color_in": "Color"
|
||||
"node.merge.name": "Merge"
|
||||
"node.merge.input.base_in": "Base"
|
||||
"node.merge.input.blend_in": "Blend"
|
||||
"node.stroke.name": "Stroke"
|
||||
"node.stroke.input.tex_in": "Input"
|
||||
"node.stroke.input.color_in": "Color"
|
||||
"node.stroke.input.radius_in": "Radius"
|
||||
"node.stroke.input.opacity_in": "Opacity"
|
||||
"node.stroke.input.inner_in": "Inner"
|
||||
"node.textgenerator.name": "Text (Legacy)"
|
||||
"node.textgenerator.input.text_in": "Text"
|
||||
"node.textgenerator.input.html_in": "Enable HTML"
|
||||
"node.textgenerator.input.color_in": "Color"
|
||||
"node.textgenerator.input.valign_in": "Vertical Align"
|
||||
"node.textgenerator.input.font_in": "Font"
|
||||
"node.textgenerator.input.font_size_in": "Font Size"
|
||||
"node.text2.name": "Text (Legacy)"
|
||||
"node.text2.input.base_in": "Base"
|
||||
"node.text2.input.pos_in": "Position"
|
||||
"node.text2.input.size_in": "Size"
|
||||
"node.text2.input.color_in": "Color"
|
||||
"node.text2.input.text_in": "Text"
|
||||
"node.text2.input.html_in": "Enable HTML"
|
||||
"node.text2.input.valign_in": "Vertical Align"
|
||||
"node.text2.input.font_in": "Font"
|
||||
"node.text2.input.font_size_in": "Font Size"
|
||||
"node.text3.name": "Text"
|
||||
"node.text3.input.base_in": "Base"
|
||||
"node.text3.input.pos_in": "Position"
|
||||
"node.text3.input.size_in": "Size"
|
||||
"node.text3.input.plain_text_in": "Text"
|
||||
"node.text3.input.text_in": "Text"
|
||||
"node.text3.input.font_family_in": "Font Family"
|
||||
"node.text3.input.font_size_in": "Font Size"
|
||||
"node.text3.input.outline_enabled_in": "Outline"
|
||||
"node.text3.input.outline_color_in": "Outline Color"
|
||||
"node.text3.input.outline_width_in": "Outline Width"
|
||||
"node.text3.input.glow_enabled_in": "Glow"
|
||||
"node.text3.input.glow_color_in": "Glow Color"
|
||||
"node.text3.input.glow_radius_in": "Glow Radius"
|
||||
"node.text3.input.color_in": "Color"
|
||||
"node.text3.input.valign_in": "Vertical Alignment"
|
||||
"node.text3.input.args_in": "Arguments"
|
||||
"node.mosaicfilter.name": "Mosaic"
|
||||
"node.mosaicfilter.input.tex_in": "Texture"
|
||||
"node.mosaicfilter.input.horiz_in": "Horizontal"
|
||||
"node.mosaicfilter.input.vert_in": "Vertical"
|
||||
"node.crop.name": "Crop"
|
||||
"node.crop.input.tex_in": "Texture"
|
||||
"node.crop.input.left_in": "Left"
|
||||
"node.crop.input.top_in": "Top"
|
||||
"node.crop.input.right_in": "Right"
|
||||
"node.crop.input.bottom_in": "Bottom"
|
||||
"node.crop.input.feather_in": "Feather"
|
||||
"node.value.name": "Value"
|
||||
"node.value.input.type_in": "Type"
|
||||
"node.value.input.value_in": "Value"
|
||||
"node.timeremap.name": "Time Remap"
|
||||
"node.timeremap.input.time_in": "Time"
|
||||
"node.timeremap.input.input_in": "Input"
|
||||
"node.shape.name": "Shape"
|
||||
"node.shape.input.type_in": "Type"
|
||||
"node.shape.input.base_in": "Base"
|
||||
"node.shape.input.pos_in": "Position"
|
||||
"node.shape.input.size_in": "Size"
|
||||
"node.shape.input.color_in": "Color"
|
||||
"node.shape.input.radius_in": "Radius"
|
||||
"node.colordifferencekey.name": "Color Difference Key"
|
||||
"node.colordifferencekey.input.tex_in": "Input"
|
||||
"node.colordifferencekey.input.garbage_in": "Garbage Matte"
|
||||
"node.colordifferencekey.input.core_in": "Core Matte"
|
||||
"node.colordifferencekey.input.color_in": "Key Color"
|
||||
"node.colordifferencekey.input.highlights_in": "Highlights"
|
||||
"node.colordifferencekey.input.shadows_in": "Shadows"
|
||||
"node.colordifferencekey.input.mask_only_in": "Show Mask Only"
|
||||
"node.despill.name": "Despill"
|
||||
"node.despill.input.tex_in": "Input"
|
||||
"node.despill.input.color_in": "Key Color"
|
||||
"node.despill.input.method_in": "Method"
|
||||
"node.despill.input.preserve_luminance_input": "Preserve Luminance"
|
||||
"node.group.name": "Group"
|
||||
"node.opacity.name": "Opacity"
|
||||
"node.opacity.input.tex_in": "Texture"
|
||||
"node.opacity.input.opacity_in": "Opacity"
|
||||
"node.flip.name": "Flip"
|
||||
"node.flip.input.tex_in": "Input"
|
||||
"node.flip.input.horiz_in": "Horizontal"
|
||||
"node.flip.input.vert_in": "Vertical"
|
||||
"node.noise.name": "Noise"
|
||||
"node.noise.input.base_in": "Base"
|
||||
"node.noise.input.strength_in": "Strength"
|
||||
"node.noise.input.color_in": "Color"
|
||||
"node.timeoffset.name": "Time Offset"
|
||||
"node.timeoffset.input.time_in": "Time"
|
||||
"node.timeoffset.input.input_in": "Input"
|
||||
"node.transition.name": "Transition"
|
||||
"node.transition.input.tex_in": "From"
|
||||
"node.transition.input.blend_in": "To"
|
||||
"node.transition.input.progress_in": "Progress"
|
||||
"node.transition.input.type_in": "Type"
|
||||
"node.transitionfx.name": "Transition FX"
|
||||
"node.transitionfx.input.tex_in": "From"
|
||||
"node.transitionfx.input.blend_in": "To"
|
||||
"node.transitionfx.input.progress_in": "Progress"
|
||||
"node.transitionfx.input.color_in": "Color"
|
||||
"node.transitionfx.input.direction_in": "Direction"
|
||||
"node.transitionfx.input.type_in": "Type"
|
||||
"node.cornerpin.name": "Corner Pin"
|
||||
"node.cornerpin.input.tex_in": "Texture"
|
||||
"node.cornerpin.input.perspective_in": "Perspective"
|
||||
"node.cornerpin.input.top_left_in": "Top Left"
|
||||
"node.cornerpin.input.top_right_in": "Top Right"
|
||||
"node.cornerpin.input.bottom_right_in": "Bottom Right"
|
||||
"node.cornerpin.input.bottom_left_in": "Bottom Left"
|
||||
"node.displaytransform.name": "Display Transform"
|
||||
"node.displaytransform.input.tex_in": "Input"
|
||||
"node.displaytransform.input.display_in": "Display"
|
||||
"node.displaytransform.input.view_in": "View"
|
||||
"node.displaytransform.input.dir_in": "Direction"
|
||||
"node.ociogradingtransformlinear.name": "OCIO Color Grading (Linear)"
|
||||
"node.ociogradingtransformlinear.input.tex_in": "Input"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_contrast": "Contrast"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_offset": "Offset"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_exposure": "Exposure"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_saturation": "Saturation"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_pivot": "Pivot"
|
||||
"node.ociogradingtransformlinear.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampWhite": "White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.name": "OCIO Color Grading (Log)"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.tex_in": "Input"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_brightness": "Lift"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_contrast": "Gain"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_gamma": "Gamma"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_saturation": "Saturation"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_pivot": "Pivot"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampWhite": "White Clamp"
|
||||
"node.whitebalance.name": "White Balance"
|
||||
"node.whitebalance.input.tex_in": "Input"
|
||||
"node.whitebalance.input.temperature_in": "Temperature (K)"
|
||||
"node.whitebalance.input.tint_in": "Tint"
|
||||
"node.ociolut.name": "OCIO LUT"
|
||||
"node.ociolut.input.tex_in": "Input"
|
||||
"node.ociolut.input.lut_file_in": "LUT File"
|
||||
"node.ociolut.input.lut_dir_in": "Direction"
|
||||
"node.threewaycolor.name": "Three-Way Color"
|
||||
"node.threewaycolor.input.tex_in": "Input"
|
||||
"node.threewaycolor.input.shadows_color_in": "Shadows"
|
||||
"node.threewaycolor.input.midtones_color_in": "Midtones"
|
||||
"node.threewaycolor.input.highlights_color_in": "Highlights"
|
||||
"node.threewaycolor.input.shadows_amount_in": "Shadows Amount"
|
||||
"node.threewaycolor.input.midtones_amount_in": "Midtones Amount"
|
||||
"node.threewaycolor.input.highlights_amount_in": "Highlights Amount"
|
||||
"node.chromakey.name": "Chroma Key"
|
||||
"node.chromakey.input.tex_in": "Input"
|
||||
"node.chromakey.input.color_key": "Key Color"
|
||||
"node.chromakey.input.lower_tolerance_in": "Lower Tolerance"
|
||||
"node.chromakey.input.upper_tolerance_in": "Upper Tolerance"
|
||||
"node.chromakey.input.garbage_in": "Garbage Matte"
|
||||
"node.chromakey.input.core_in": "Core Matte"
|
||||
"node.chromakey.input.highlights_in": "Highlights"
|
||||
"node.chromakey.input.shadows_in": "Shadows"
|
||||
"node.chromakey.input.invert_in": "Invert Mask"
|
||||
"node.chromakey.input.mask_only_in": "Show Mask Only"
|
||||
"node.mask.name": "Mask"
|
||||
"node.mask.input.base_in": "Texture"
|
||||
"node.mask.input.points_in": "Points"
|
||||
"node.mask.input.color_in": "Color"
|
||||
"node.mask.input.invert_in": "Invert"
|
||||
"node.mask.input.feather_in": "Feather"
|
||||
"node.dropshadow.name": "Drop Shadow"
|
||||
"node.dropshadow.input.tex_in": "Texture"
|
||||
"node.dropshadow.input.color_in": "Color"
|
||||
"node.dropshadow.input.distance_in": "Distance"
|
||||
"node.dropshadow.input.angle_in": "Angle"
|
||||
"node.dropshadow.input.radius_in": "Softness"
|
||||
"node.dropshadow.input.opacity_in": "Opacity"
|
||||
"node.dropshadow.input.fast_in": "Faster (Lower Quality)"
|
||||
"node.timeformat.name": "Time Format"
|
||||
"node.timeformat.input.time_in": "Time"
|
||||
"node.timeformat.input.format_in": "Format"
|
||||
"node.timeformat.input.localtime_in": "Interpret time as local time"
|
||||
"node.wave.name": "Wave"
|
||||
"node.wave.input.tex_in": "Input"
|
||||
"node.wave.input.frequency_in": "Frequency"
|
||||
"node.wave.input.intensity_in": "Intensity"
|
||||
"node.wave.input.evolution_in": "Evolution"
|
||||
"node.wave.input.vertical_in": "Direction"
|
||||
"node.tile.name": "Tile"
|
||||
"node.tile.input.tex_in": "Input"
|
||||
"node.tile.input.scale_in": "Scale"
|
||||
"node.tile.input.position_in": "Position"
|
||||
"node.tile.input.anchor_in": "Anchor"
|
||||
"node.tile.input.mirrorx_in": "Mirror Horizontally"
|
||||
"node.tile.input.mirrory_in": "Mirror Vertically"
|
||||
"node.swirl.name": "Swirl"
|
||||
"node.swirl.input.tex_in": "Input"
|
||||
"node.swirl.input.radius_in": "Radius"
|
||||
"node.swirl.input.angle_in": "Angle"
|
||||
"node.swirl.input.pos_in": "Position"
|
||||
"node.ripple.name": "Ripple"
|
||||
"node.ripple.input.tex_in": "Input"
|
||||
"node.ripple.input.evolution_in": "Evolution"
|
||||
"node.ripple.input.intensity_in": "Intensity"
|
||||
"node.ripple.input.frequency_in": "Frequency"
|
||||
"node.ripple.input.position_in": "Position"
|
||||
"node.ripple.input.stretch_in": "Stretch"
|
||||
"node.colormatrix.name": "Color Matrix"
|
||||
"node.colormatrix.input.tex_in": "Input"
|
||||
"node.colormatrix.input.m0": "R <- R"
|
||||
"node.colormatrix.input.m1": "R <- G"
|
||||
"node.colormatrix.input.m2": "R <- B"
|
||||
"node.colormatrix.input.m3": "R <- A"
|
||||
"node.colormatrix.input.m4": "G <- R"
|
||||
"node.colormatrix.input.m5": "G <- G"
|
||||
"node.colormatrix.input.m6": "G <- B"
|
||||
"node.colormatrix.input.m7": "G <- A"
|
||||
"node.colormatrix.input.m8": "B <- R"
|
||||
"node.colormatrix.input.m9": "B <- G"
|
||||
"node.colormatrix.input.m10": "B <- B"
|
||||
"node.colormatrix.input.m11": "B <- A"
|
||||
"node.colormatrix.input.m12": "A <- R"
|
||||
"node.colormatrix.input.m13": "A <- G"
|
||||
"node.colormatrix.input.m14": "A <- B"
|
||||
"node.colormatrix.input.m15": "A <- A"
|
||||
"node.dilate.name": "Dilate"
|
||||
"node.dilate.input.tex_in": "Input"
|
||||
"node.dilate.input.radius_in": "Radius"
|
||||
"node.edgedetect.name": "Edge Detect"
|
||||
"node.edgedetect.input.tex_in": "Input"
|
||||
"node.edgedetect.input.threshold_in": "Threshold"
|
||||
"node.erode.name": "Erode"
|
||||
"node.erode.input.tex_in": "Input"
|
||||
"node.erode.input.radius_in": "Radius"
|
||||
"node.colorcorrect.name": "Color Correct"
|
||||
"node.colorcorrect.input.tex_in": "Input"
|
||||
"node.colorcorrect.input.saturation_in": "Saturation"
|
||||
"node.colorcorrect.input.contrast_in": "Contrast"
|
||||
"node.colorcorrect.input.gamma_in": "Gamma"
|
||||
"node.colorcorrect.input.gain_in": "Gain"
|
||||
"node.colorcorrect.input.offset_in": "Offset"
|
||||
"node.gamma.name": "Gamma"
|
||||
"node.gamma.input.tex_in": "Input"
|
||||
"node.gamma.input.gamma_in": "Gamma"
|
||||
"node.saturation.name": "Saturation"
|
||||
"node.saturation.input.tex_in": "Input"
|
||||
"node.saturation.input.saturation_in": "Saturation"
|
||||
"node.invert.name": "Invert"
|
||||
"node.invert.input.tex_in": "Input"
|
||||
"node.invert.input.invert_r_in": "Invert Red"
|
||||
"node.invert.input.invert_g_in": "Invert Green"
|
||||
"node.invert.input.invert_b_in": "Invert Blue"
|
||||
"node.invert.input.invert_a_in": "Invert Alpha"
|
||||
"node.clamp.name": "Clamp"
|
||||
"node.clamp.input.tex_in": "Input"
|
||||
"node.clamp.input.min_in": "Min"
|
||||
"node.clamp.input.max_in": "Max"
|
||||
"node.grade.name": "Grade"
|
||||
"node.grade.input.tex_in": "Input"
|
||||
"node.grade.input.blackpoint_in": "Black Point"
|
||||
"node.grade.input.whitepoint_in": "White Point"
|
||||
"node.grade.input.black_in": "Black Output"
|
||||
"node.grade.input.white_in": "White Output"
|
||||
"node.grade.input.gamma_in": "Gamma"
|
||||
"node.dirblur.name": "Directional Blur"
|
||||
"node.dirblur.input.tex_in": "Input"
|
||||
"node.dirblur.input.amount_in": "Amount"
|
||||
"node.dirblur.input.angle_in": "Angle"
|
||||
"node.sharpen.name": "Sharpen"
|
||||
"node.sharpen.input.tex_in": "Input"
|
||||
"node.sharpen.input.amount_in": "Amount"
|
||||
"node.dissolve.name": "Dissolve"
|
||||
"node.dissolve.input.tex_in": "Input"
|
||||
"node.dissolve.input.blend_in": "Blend"
|
||||
"node.dissolve.input.mix_in": "Mix"
|
||||
"node.keymix.name": "KeyMix"
|
||||
"node.keymix.input.tex_in": "Input"
|
||||
"node.keymix.input.blend_in": "Blend"
|
||||
"node.keymix.input.mask_in": "Mask"
|
||||
"node.premult.name": "Premultiply"
|
||||
"node.premult.input.tex_in": "Input"
|
||||
"node.premult.input.premult_channel_in": "Channel"
|
||||
"node.unpremult.name": "Unpremultiply"
|
||||
"node.unpremult.input.tex_in": "Input"
|
||||
"node.unpremult.input.unpremult_channel_in": "Channel"
|
||||
"node.position.name": "Position"
|
||||
"node.position.input.tex_in": "Input"
|
||||
"node.position.input.offset_in": "Translate"
|
||||
"node.mirror.name": "Mirror"
|
||||
"node.mirror.input.tex_in": "Input"
|
||||
"node.mirror.input.horizontal_in": "Horizontal (flop)"
|
||||
"node.mirror.input.vertical_in": "Vertical (flip)"
|
||||
"node.checkerboard.name": "Checkerboard"
|
||||
"node.checkerboard.input.base_in": "Base"
|
||||
"node.checkerboard.input.size_in": "Box Size"
|
||||
"node.checkerboard.input.color1_in": "Color 1"
|
||||
"node.checkerboard.input.color2_in": "Color 2"
|
||||
"node.colorbars.name": "Color Bars"
|
||||
"node.colorbars.input.base_in": "Base"
|
||||
"node.colorbars.input.standard_in": "Standard"
|
||||
"node.ramp.name": "Ramp"
|
||||
"node.ramp.input.base_in": "Base"
|
||||
"node.ramp.input.point0_in": "Point 0"
|
||||
"node.ramp.input.point1_in": "Point 1"
|
||||
"node.ramp.input.color0_in": "Color 0"
|
||||
"node.ramp.input.color1_in": "Color 1"
|
||||
"node.multicam.name": "Multi-Cam"
|
||||
"node.multicam.input.current_in": "Current"
|
||||
"node.multicam.input.sources_in": "Sources"
|
||||
"node.multicam.input.sequence_in": "Sequence"
|
||||
"node.multicam.input.sequence_type_in": "Sequence Type"
|
||||
"status.ready": "Ready"
|
||||
"status.cache": "Cache: Enabled"
|
||||
"status.proxy.on": "Proxy: On"
|
||||
@@ -479,6 +853,8 @@
|
||||
"node.category.group": "Group"
|
||||
"project.context.new": "New"
|
||||
"project.new_sequence": "New Sequence"
|
||||
"project.add_text_footage": "Add Text Footage"
|
||||
"timeline.context.add_adjustment_layer": "Add Adjustment Layer"
|
||||
"project.context.reveal_in_finder": "Reveal in Finder"
|
||||
"project.context.replace_footage": "Replace Footage"
|
||||
"project.context.rename": "Rename"
|
||||
|
||||
@@ -171,6 +171,380 @@
|
||||
"multicam.switch_9": "Cambiar a cámara 9"
|
||||
"effect_library.hint": "Haz doble clic para añadir al clip seleccionado"
|
||||
"effect_library.group.builtin": "Integradas"
|
||||
"effect_library.group.color": "Color"
|
||||
"effect_library.group.filter": "Filter"
|
||||
"effect_library.group.distort": "Distort"
|
||||
"effect_library.group.keying": "Keying"
|
||||
"effect_library.group.generator": "Generator"
|
||||
"effect_library.group.math": "Math"
|
||||
"effect_library.group.time": "Time"
|
||||
"effect_library.group.channel": "Channel"
|
||||
"effect_library.group.transition": "Transition"
|
||||
"effect_library.group.general": "General"
|
||||
# Built-in effect (node) and effect-parameter (input) names. The effect
|
||||
# library and the inspector build these keys at runtime as
|
||||
# `node.<type-id suffix>.name` and `node.<type-id suffix>.input.<input id>`.
|
||||
"node.polygon.name": "Polygon"
|
||||
"node.polygon.input.base_in": "Base"
|
||||
"node.polygon.input.points_in": "Points"
|
||||
"node.polygon.input.color_in": "Color"
|
||||
"node.ortho.name": "Orthographic Matrix"
|
||||
"node.ortho.input.pos_in": "Position"
|
||||
"node.ortho.input.rot_in": "Rotation"
|
||||
"node.ortho.input.scale_in": "Scale"
|
||||
"node.ortho.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.ortho.input.anchor_in": "Anchor Point"
|
||||
"node.transform.name": "Transform"
|
||||
"node.transform.input.tex_in": "Texture"
|
||||
"node.transform.input.parent_in": "Parent"
|
||||
"node.transform.input.autoscale_in": "Auto-Scale"
|
||||
"node.transform.input.interpolation_in": "Interpolation"
|
||||
"node.transform.input.pos_in": "Position"
|
||||
"node.transform.input.rot_in": "Rotation"
|
||||
"node.transform.input.scale_in": "Scale"
|
||||
"node.transform.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.transform.input.anchor_in": "Anchor Point"
|
||||
"node.volume.name": "Volume"
|
||||
"node.volume.input.samples_in": "Samples"
|
||||
"node.volume.input.volume_in": "Volume"
|
||||
"node.pan.name": "Pan"
|
||||
"node.pan.input.samples_in": "Samples"
|
||||
"node.pan.input.panning_in": "Pan"
|
||||
"node.math.name": "Math"
|
||||
"node.math.input.method_in": "Method"
|
||||
"node.math.input.param_a_in": "Value"
|
||||
"node.math.input.param_b_in": "Value"
|
||||
"node.time.name": "Time"
|
||||
"node.time.input.enabled_in": "Enabled"
|
||||
"node.trigonometry.name": "Trigonometry"
|
||||
"node.trigonometry.input.method_in": "Method"
|
||||
"node.trigonometry.input.x_in": "Value"
|
||||
"node.blur.name": "Blur"
|
||||
"node.blur.input.tex_in": "Input"
|
||||
"node.blur.input.method_in": "Method"
|
||||
"node.blur.input.radius_in": "Radius"
|
||||
"node.blur.input.horiz_in": "Horizontal"
|
||||
"node.blur.input.vert_in": "Vertical"
|
||||
"node.blur.input.directional_degrees_in": "Direction"
|
||||
"node.blur.input.radial_center_in": "Center"
|
||||
"node.blur.input.repeat_edge_pixels_in": "Repeat Edge Pixels"
|
||||
"node.solidgenerator.name": "Solid"
|
||||
"node.solidgenerator.input.color_in": "Color"
|
||||
"node.merge.name": "Merge"
|
||||
"node.merge.input.base_in": "Base"
|
||||
"node.merge.input.blend_in": "Blend"
|
||||
"node.stroke.name": "Stroke"
|
||||
"node.stroke.input.tex_in": "Input"
|
||||
"node.stroke.input.color_in": "Color"
|
||||
"node.stroke.input.radius_in": "Radius"
|
||||
"node.stroke.input.opacity_in": "Opacity"
|
||||
"node.stroke.input.inner_in": "Inner"
|
||||
"node.textgenerator.name": "Text (Legacy)"
|
||||
"node.textgenerator.input.text_in": "Text"
|
||||
"node.textgenerator.input.html_in": "Enable HTML"
|
||||
"node.textgenerator.input.color_in": "Color"
|
||||
"node.textgenerator.input.valign_in": "Vertical Align"
|
||||
"node.textgenerator.input.font_in": "Font"
|
||||
"node.textgenerator.input.font_size_in": "Font Size"
|
||||
"node.text2.name": "Text (Legacy)"
|
||||
"node.text2.input.base_in": "Base"
|
||||
"node.text2.input.pos_in": "Position"
|
||||
"node.text2.input.size_in": "Size"
|
||||
"node.text2.input.color_in": "Color"
|
||||
"node.text2.input.text_in": "Text"
|
||||
"node.text2.input.html_in": "Enable HTML"
|
||||
"node.text2.input.valign_in": "Vertical Align"
|
||||
"node.text2.input.font_in": "Font"
|
||||
"node.text2.input.font_size_in": "Font Size"
|
||||
"node.text3.name": "Text"
|
||||
"node.text3.input.base_in": "Base"
|
||||
"node.text3.input.pos_in": "Position"
|
||||
"node.text3.input.size_in": "Size"
|
||||
"node.text3.input.plain_text_in": "Text"
|
||||
"node.text3.input.text_in": "Text"
|
||||
"node.text3.input.font_family_in": "Font Family"
|
||||
"node.text3.input.font_size_in": "Font Size"
|
||||
"node.text3.input.outline_enabled_in": "Outline"
|
||||
"node.text3.input.outline_color_in": "Outline Color"
|
||||
"node.text3.input.outline_width_in": "Outline Width"
|
||||
"node.text3.input.glow_enabled_in": "Glow"
|
||||
"node.text3.input.glow_color_in": "Glow Color"
|
||||
"node.text3.input.glow_radius_in": "Glow Radius"
|
||||
"node.text3.input.color_in": "Color"
|
||||
"node.text3.input.valign_in": "Vertical Alignment"
|
||||
"node.text3.input.args_in": "Arguments"
|
||||
"node.mosaicfilter.name": "Mosaic"
|
||||
"node.mosaicfilter.input.tex_in": "Texture"
|
||||
"node.mosaicfilter.input.horiz_in": "Horizontal"
|
||||
"node.mosaicfilter.input.vert_in": "Vertical"
|
||||
"node.crop.name": "Crop"
|
||||
"node.crop.input.tex_in": "Texture"
|
||||
"node.crop.input.left_in": "Left"
|
||||
"node.crop.input.top_in": "Top"
|
||||
"node.crop.input.right_in": "Right"
|
||||
"node.crop.input.bottom_in": "Bottom"
|
||||
"node.crop.input.feather_in": "Feather"
|
||||
"node.value.name": "Value"
|
||||
"node.value.input.type_in": "Type"
|
||||
"node.value.input.value_in": "Value"
|
||||
"node.timeremap.name": "Time Remap"
|
||||
"node.timeremap.input.time_in": "Time"
|
||||
"node.timeremap.input.input_in": "Input"
|
||||
"node.shape.name": "Shape"
|
||||
"node.shape.input.type_in": "Type"
|
||||
"node.shape.input.base_in": "Base"
|
||||
"node.shape.input.pos_in": "Position"
|
||||
"node.shape.input.size_in": "Size"
|
||||
"node.shape.input.color_in": "Color"
|
||||
"node.shape.input.radius_in": "Radius"
|
||||
"node.colordifferencekey.name": "Color Difference Key"
|
||||
"node.colordifferencekey.input.tex_in": "Input"
|
||||
"node.colordifferencekey.input.garbage_in": "Garbage Matte"
|
||||
"node.colordifferencekey.input.core_in": "Core Matte"
|
||||
"node.colordifferencekey.input.color_in": "Key Color"
|
||||
"node.colordifferencekey.input.highlights_in": "Highlights"
|
||||
"node.colordifferencekey.input.shadows_in": "Shadows"
|
||||
"node.colordifferencekey.input.mask_only_in": "Show Mask Only"
|
||||
"node.despill.name": "Despill"
|
||||
"node.despill.input.tex_in": "Input"
|
||||
"node.despill.input.color_in": "Key Color"
|
||||
"node.despill.input.method_in": "Method"
|
||||
"node.despill.input.preserve_luminance_input": "Preserve Luminance"
|
||||
"node.group.name": "Group"
|
||||
"node.opacity.name": "Opacity"
|
||||
"node.opacity.input.tex_in": "Texture"
|
||||
"node.opacity.input.opacity_in": "Opacity"
|
||||
"node.flip.name": "Flip"
|
||||
"node.flip.input.tex_in": "Input"
|
||||
"node.flip.input.horiz_in": "Horizontal"
|
||||
"node.flip.input.vert_in": "Vertical"
|
||||
"node.noise.name": "Noise"
|
||||
"node.noise.input.base_in": "Base"
|
||||
"node.noise.input.strength_in": "Strength"
|
||||
"node.noise.input.color_in": "Color"
|
||||
"node.timeoffset.name": "Time Offset"
|
||||
"node.timeoffset.input.time_in": "Time"
|
||||
"node.timeoffset.input.input_in": "Input"
|
||||
"node.transition.name": "Transition"
|
||||
"node.transition.input.tex_in": "From"
|
||||
"node.transition.input.blend_in": "To"
|
||||
"node.transition.input.progress_in": "Progress"
|
||||
"node.transition.input.type_in": "Type"
|
||||
"node.transitionfx.name": "Transition FX"
|
||||
"node.transitionfx.input.tex_in": "From"
|
||||
"node.transitionfx.input.blend_in": "To"
|
||||
"node.transitionfx.input.progress_in": "Progress"
|
||||
"node.transitionfx.input.color_in": "Color"
|
||||
"node.transitionfx.input.direction_in": "Direction"
|
||||
"node.transitionfx.input.type_in": "Type"
|
||||
"node.cornerpin.name": "Corner Pin"
|
||||
"node.cornerpin.input.tex_in": "Texture"
|
||||
"node.cornerpin.input.perspective_in": "Perspective"
|
||||
"node.cornerpin.input.top_left_in": "Top Left"
|
||||
"node.cornerpin.input.top_right_in": "Top Right"
|
||||
"node.cornerpin.input.bottom_right_in": "Bottom Right"
|
||||
"node.cornerpin.input.bottom_left_in": "Bottom Left"
|
||||
"node.displaytransform.name": "Display Transform"
|
||||
"node.displaytransform.input.tex_in": "Input"
|
||||
"node.displaytransform.input.display_in": "Display"
|
||||
"node.displaytransform.input.view_in": "View"
|
||||
"node.displaytransform.input.dir_in": "Direction"
|
||||
"node.ociogradingtransformlinear.name": "OCIO Color Grading (Linear)"
|
||||
"node.ociogradingtransformlinear.input.tex_in": "Input"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_contrast": "Contrast"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_offset": "Offset"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_exposure": "Exposure"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_saturation": "Saturation"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_pivot": "Pivot"
|
||||
"node.ociogradingtransformlinear.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampWhite": "White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.name": "OCIO Color Grading (Log)"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.tex_in": "Input"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_brightness": "Lift"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_contrast": "Gain"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_gamma": "Gamma"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_saturation": "Saturation"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_pivot": "Pivot"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampWhite": "White Clamp"
|
||||
"node.whitebalance.name": "White Balance"
|
||||
"node.whitebalance.input.tex_in": "Input"
|
||||
"node.whitebalance.input.temperature_in": "Temperature (K)"
|
||||
"node.whitebalance.input.tint_in": "Tint"
|
||||
"node.ociolut.name": "OCIO LUT"
|
||||
"node.ociolut.input.tex_in": "Input"
|
||||
"node.ociolut.input.lut_file_in": "LUT File"
|
||||
"node.ociolut.input.lut_dir_in": "Direction"
|
||||
"node.threewaycolor.name": "Three-Way Color"
|
||||
"node.threewaycolor.input.tex_in": "Input"
|
||||
"node.threewaycolor.input.shadows_color_in": "Shadows"
|
||||
"node.threewaycolor.input.midtones_color_in": "Midtones"
|
||||
"node.threewaycolor.input.highlights_color_in": "Highlights"
|
||||
"node.threewaycolor.input.shadows_amount_in": "Shadows Amount"
|
||||
"node.threewaycolor.input.midtones_amount_in": "Midtones Amount"
|
||||
"node.threewaycolor.input.highlights_amount_in": "Highlights Amount"
|
||||
"node.chromakey.name": "Chroma Key"
|
||||
"node.chromakey.input.tex_in": "Input"
|
||||
"node.chromakey.input.color_key": "Key Color"
|
||||
"node.chromakey.input.lower_tolerance_in": "Lower Tolerance"
|
||||
"node.chromakey.input.upper_tolerance_in": "Upper Tolerance"
|
||||
"node.chromakey.input.garbage_in": "Garbage Matte"
|
||||
"node.chromakey.input.core_in": "Core Matte"
|
||||
"node.chromakey.input.highlights_in": "Highlights"
|
||||
"node.chromakey.input.shadows_in": "Shadows"
|
||||
"node.chromakey.input.invert_in": "Invert Mask"
|
||||
"node.chromakey.input.mask_only_in": "Show Mask Only"
|
||||
"node.mask.name": "Mask"
|
||||
"node.mask.input.base_in": "Texture"
|
||||
"node.mask.input.points_in": "Points"
|
||||
"node.mask.input.color_in": "Color"
|
||||
"node.mask.input.invert_in": "Invert"
|
||||
"node.mask.input.feather_in": "Feather"
|
||||
"node.dropshadow.name": "Drop Shadow"
|
||||
"node.dropshadow.input.tex_in": "Texture"
|
||||
"node.dropshadow.input.color_in": "Color"
|
||||
"node.dropshadow.input.distance_in": "Distance"
|
||||
"node.dropshadow.input.angle_in": "Angle"
|
||||
"node.dropshadow.input.radius_in": "Softness"
|
||||
"node.dropshadow.input.opacity_in": "Opacity"
|
||||
"node.dropshadow.input.fast_in": "Faster (Lower Quality)"
|
||||
"node.timeformat.name": "Time Format"
|
||||
"node.timeformat.input.time_in": "Time"
|
||||
"node.timeformat.input.format_in": "Format"
|
||||
"node.timeformat.input.localtime_in": "Interpret time as local time"
|
||||
"node.wave.name": "Wave"
|
||||
"node.wave.input.tex_in": "Input"
|
||||
"node.wave.input.frequency_in": "Frequency"
|
||||
"node.wave.input.intensity_in": "Intensity"
|
||||
"node.wave.input.evolution_in": "Evolution"
|
||||
"node.wave.input.vertical_in": "Direction"
|
||||
"node.tile.name": "Tile"
|
||||
"node.tile.input.tex_in": "Input"
|
||||
"node.tile.input.scale_in": "Scale"
|
||||
"node.tile.input.position_in": "Position"
|
||||
"node.tile.input.anchor_in": "Anchor"
|
||||
"node.tile.input.mirrorx_in": "Mirror Horizontally"
|
||||
"node.tile.input.mirrory_in": "Mirror Vertically"
|
||||
"node.swirl.name": "Swirl"
|
||||
"node.swirl.input.tex_in": "Input"
|
||||
"node.swirl.input.radius_in": "Radius"
|
||||
"node.swirl.input.angle_in": "Angle"
|
||||
"node.swirl.input.pos_in": "Position"
|
||||
"node.ripple.name": "Ripple"
|
||||
"node.ripple.input.tex_in": "Input"
|
||||
"node.ripple.input.evolution_in": "Evolution"
|
||||
"node.ripple.input.intensity_in": "Intensity"
|
||||
"node.ripple.input.frequency_in": "Frequency"
|
||||
"node.ripple.input.position_in": "Position"
|
||||
"node.ripple.input.stretch_in": "Stretch"
|
||||
"node.colormatrix.name": "Color Matrix"
|
||||
"node.colormatrix.input.tex_in": "Input"
|
||||
"node.colormatrix.input.m0": "R <- R"
|
||||
"node.colormatrix.input.m1": "R <- G"
|
||||
"node.colormatrix.input.m2": "R <- B"
|
||||
"node.colormatrix.input.m3": "R <- A"
|
||||
"node.colormatrix.input.m4": "G <- R"
|
||||
"node.colormatrix.input.m5": "G <- G"
|
||||
"node.colormatrix.input.m6": "G <- B"
|
||||
"node.colormatrix.input.m7": "G <- A"
|
||||
"node.colormatrix.input.m8": "B <- R"
|
||||
"node.colormatrix.input.m9": "B <- G"
|
||||
"node.colormatrix.input.m10": "B <- B"
|
||||
"node.colormatrix.input.m11": "B <- A"
|
||||
"node.colormatrix.input.m12": "A <- R"
|
||||
"node.colormatrix.input.m13": "A <- G"
|
||||
"node.colormatrix.input.m14": "A <- B"
|
||||
"node.colormatrix.input.m15": "A <- A"
|
||||
"node.dilate.name": "Dilate"
|
||||
"node.dilate.input.tex_in": "Input"
|
||||
"node.dilate.input.radius_in": "Radius"
|
||||
"node.edgedetect.name": "Edge Detect"
|
||||
"node.edgedetect.input.tex_in": "Input"
|
||||
"node.edgedetect.input.threshold_in": "Threshold"
|
||||
"node.erode.name": "Erode"
|
||||
"node.erode.input.tex_in": "Input"
|
||||
"node.erode.input.radius_in": "Radius"
|
||||
"node.colorcorrect.name": "Color Correct"
|
||||
"node.colorcorrect.input.tex_in": "Input"
|
||||
"node.colorcorrect.input.saturation_in": "Saturation"
|
||||
"node.colorcorrect.input.contrast_in": "Contrast"
|
||||
"node.colorcorrect.input.gamma_in": "Gamma"
|
||||
"node.colorcorrect.input.gain_in": "Gain"
|
||||
"node.colorcorrect.input.offset_in": "Offset"
|
||||
"node.gamma.name": "Gamma"
|
||||
"node.gamma.input.tex_in": "Input"
|
||||
"node.gamma.input.gamma_in": "Gamma"
|
||||
"node.saturation.name": "Saturation"
|
||||
"node.saturation.input.tex_in": "Input"
|
||||
"node.saturation.input.saturation_in": "Saturation"
|
||||
"node.invert.name": "Invert"
|
||||
"node.invert.input.tex_in": "Input"
|
||||
"node.invert.input.invert_r_in": "Invert Red"
|
||||
"node.invert.input.invert_g_in": "Invert Green"
|
||||
"node.invert.input.invert_b_in": "Invert Blue"
|
||||
"node.invert.input.invert_a_in": "Invert Alpha"
|
||||
"node.clamp.name": "Clamp"
|
||||
"node.clamp.input.tex_in": "Input"
|
||||
"node.clamp.input.min_in": "Min"
|
||||
"node.clamp.input.max_in": "Max"
|
||||
"node.grade.name": "Grade"
|
||||
"node.grade.input.tex_in": "Input"
|
||||
"node.grade.input.blackpoint_in": "Black Point"
|
||||
"node.grade.input.whitepoint_in": "White Point"
|
||||
"node.grade.input.black_in": "Black Output"
|
||||
"node.grade.input.white_in": "White Output"
|
||||
"node.grade.input.gamma_in": "Gamma"
|
||||
"node.dirblur.name": "Directional Blur"
|
||||
"node.dirblur.input.tex_in": "Input"
|
||||
"node.dirblur.input.amount_in": "Amount"
|
||||
"node.dirblur.input.angle_in": "Angle"
|
||||
"node.sharpen.name": "Sharpen"
|
||||
"node.sharpen.input.tex_in": "Input"
|
||||
"node.sharpen.input.amount_in": "Amount"
|
||||
"node.dissolve.name": "Dissolve"
|
||||
"node.dissolve.input.tex_in": "Input"
|
||||
"node.dissolve.input.blend_in": "Blend"
|
||||
"node.dissolve.input.mix_in": "Mix"
|
||||
"node.keymix.name": "KeyMix"
|
||||
"node.keymix.input.tex_in": "Input"
|
||||
"node.keymix.input.blend_in": "Blend"
|
||||
"node.keymix.input.mask_in": "Mask"
|
||||
"node.premult.name": "Premultiply"
|
||||
"node.premult.input.tex_in": "Input"
|
||||
"node.premult.input.premult_channel_in": "Channel"
|
||||
"node.unpremult.name": "Unpremultiply"
|
||||
"node.unpremult.input.tex_in": "Input"
|
||||
"node.unpremult.input.unpremult_channel_in": "Channel"
|
||||
"node.position.name": "Position"
|
||||
"node.position.input.tex_in": "Input"
|
||||
"node.position.input.offset_in": "Translate"
|
||||
"node.mirror.name": "Mirror"
|
||||
"node.mirror.input.tex_in": "Input"
|
||||
"node.mirror.input.horizontal_in": "Horizontal (flop)"
|
||||
"node.mirror.input.vertical_in": "Vertical (flip)"
|
||||
"node.checkerboard.name": "Checkerboard"
|
||||
"node.checkerboard.input.base_in": "Base"
|
||||
"node.checkerboard.input.size_in": "Box Size"
|
||||
"node.checkerboard.input.color1_in": "Color 1"
|
||||
"node.checkerboard.input.color2_in": "Color 2"
|
||||
"node.colorbars.name": "Color Bars"
|
||||
"node.colorbars.input.base_in": "Base"
|
||||
"node.colorbars.input.standard_in": "Standard"
|
||||
"node.ramp.name": "Ramp"
|
||||
"node.ramp.input.base_in": "Base"
|
||||
"node.ramp.input.point0_in": "Point 0"
|
||||
"node.ramp.input.point1_in": "Point 1"
|
||||
"node.ramp.input.color0_in": "Color 0"
|
||||
"node.ramp.input.color1_in": "Color 1"
|
||||
"node.multicam.name": "Multi-Cam"
|
||||
"node.multicam.input.current_in": "Current"
|
||||
"node.multicam.input.sources_in": "Sources"
|
||||
"node.multicam.input.sequence_in": "Sequence"
|
||||
"node.multicam.input.sequence_type_in": "Sequence Type"
|
||||
"status.ready": "Listo"
|
||||
"status.cache": "Caché: activada"
|
||||
"status.proxy.on": "Proxy: activado"
|
||||
@@ -481,6 +855,8 @@
|
||||
"node.category.group": "Grupo"
|
||||
"project.context.new": "Nuevo"
|
||||
"project.new_sequence": "Nueva secuencia"
|
||||
"project.add_text_footage": "Add Text Footage"
|
||||
"timeline.context.add_adjustment_layer": "Add Adjustment Layer"
|
||||
"project.context.reveal_in_finder": "Mostrar en Finder"
|
||||
"project.context.replace_footage": "Reemplazar material"
|
||||
"project.context.rename": "Cambiar nombre"
|
||||
|
||||
@@ -171,6 +171,380 @@
|
||||
"multicam.switch_9": "Passer à la caméra 9"
|
||||
"effect_library.hint": "Double-cliquer pour ajouter au clip sélectionné"
|
||||
"effect_library.group.builtin": "Intégré"
|
||||
"effect_library.group.color": "Color"
|
||||
"effect_library.group.filter": "Filter"
|
||||
"effect_library.group.distort": "Distort"
|
||||
"effect_library.group.keying": "Keying"
|
||||
"effect_library.group.generator": "Generator"
|
||||
"effect_library.group.math": "Math"
|
||||
"effect_library.group.time": "Time"
|
||||
"effect_library.group.channel": "Channel"
|
||||
"effect_library.group.transition": "Transition"
|
||||
"effect_library.group.general": "General"
|
||||
# Built-in effect (node) and effect-parameter (input) names. The effect
|
||||
# library and the inspector build these keys at runtime as
|
||||
# `node.<type-id suffix>.name` and `node.<type-id suffix>.input.<input id>`.
|
||||
"node.polygon.name": "Polygon"
|
||||
"node.polygon.input.base_in": "Base"
|
||||
"node.polygon.input.points_in": "Points"
|
||||
"node.polygon.input.color_in": "Color"
|
||||
"node.ortho.name": "Orthographic Matrix"
|
||||
"node.ortho.input.pos_in": "Position"
|
||||
"node.ortho.input.rot_in": "Rotation"
|
||||
"node.ortho.input.scale_in": "Scale"
|
||||
"node.ortho.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.ortho.input.anchor_in": "Anchor Point"
|
||||
"node.transform.name": "Transform"
|
||||
"node.transform.input.tex_in": "Texture"
|
||||
"node.transform.input.parent_in": "Parent"
|
||||
"node.transform.input.autoscale_in": "Auto-Scale"
|
||||
"node.transform.input.interpolation_in": "Interpolation"
|
||||
"node.transform.input.pos_in": "Position"
|
||||
"node.transform.input.rot_in": "Rotation"
|
||||
"node.transform.input.scale_in": "Scale"
|
||||
"node.transform.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.transform.input.anchor_in": "Anchor Point"
|
||||
"node.volume.name": "Volume"
|
||||
"node.volume.input.samples_in": "Samples"
|
||||
"node.volume.input.volume_in": "Volume"
|
||||
"node.pan.name": "Pan"
|
||||
"node.pan.input.samples_in": "Samples"
|
||||
"node.pan.input.panning_in": "Pan"
|
||||
"node.math.name": "Math"
|
||||
"node.math.input.method_in": "Method"
|
||||
"node.math.input.param_a_in": "Value"
|
||||
"node.math.input.param_b_in": "Value"
|
||||
"node.time.name": "Time"
|
||||
"node.time.input.enabled_in": "Enabled"
|
||||
"node.trigonometry.name": "Trigonometry"
|
||||
"node.trigonometry.input.method_in": "Method"
|
||||
"node.trigonometry.input.x_in": "Value"
|
||||
"node.blur.name": "Blur"
|
||||
"node.blur.input.tex_in": "Input"
|
||||
"node.blur.input.method_in": "Method"
|
||||
"node.blur.input.radius_in": "Radius"
|
||||
"node.blur.input.horiz_in": "Horizontal"
|
||||
"node.blur.input.vert_in": "Vertical"
|
||||
"node.blur.input.directional_degrees_in": "Direction"
|
||||
"node.blur.input.radial_center_in": "Center"
|
||||
"node.blur.input.repeat_edge_pixels_in": "Repeat Edge Pixels"
|
||||
"node.solidgenerator.name": "Solid"
|
||||
"node.solidgenerator.input.color_in": "Color"
|
||||
"node.merge.name": "Merge"
|
||||
"node.merge.input.base_in": "Base"
|
||||
"node.merge.input.blend_in": "Blend"
|
||||
"node.stroke.name": "Stroke"
|
||||
"node.stroke.input.tex_in": "Input"
|
||||
"node.stroke.input.color_in": "Color"
|
||||
"node.stroke.input.radius_in": "Radius"
|
||||
"node.stroke.input.opacity_in": "Opacity"
|
||||
"node.stroke.input.inner_in": "Inner"
|
||||
"node.textgenerator.name": "Text (Legacy)"
|
||||
"node.textgenerator.input.text_in": "Text"
|
||||
"node.textgenerator.input.html_in": "Enable HTML"
|
||||
"node.textgenerator.input.color_in": "Color"
|
||||
"node.textgenerator.input.valign_in": "Vertical Align"
|
||||
"node.textgenerator.input.font_in": "Font"
|
||||
"node.textgenerator.input.font_size_in": "Font Size"
|
||||
"node.text2.name": "Text (Legacy)"
|
||||
"node.text2.input.base_in": "Base"
|
||||
"node.text2.input.pos_in": "Position"
|
||||
"node.text2.input.size_in": "Size"
|
||||
"node.text2.input.color_in": "Color"
|
||||
"node.text2.input.text_in": "Text"
|
||||
"node.text2.input.html_in": "Enable HTML"
|
||||
"node.text2.input.valign_in": "Vertical Align"
|
||||
"node.text2.input.font_in": "Font"
|
||||
"node.text2.input.font_size_in": "Font Size"
|
||||
"node.text3.name": "Text"
|
||||
"node.text3.input.base_in": "Base"
|
||||
"node.text3.input.pos_in": "Position"
|
||||
"node.text3.input.size_in": "Size"
|
||||
"node.text3.input.plain_text_in": "Text"
|
||||
"node.text3.input.text_in": "Text"
|
||||
"node.text3.input.font_family_in": "Font Family"
|
||||
"node.text3.input.font_size_in": "Font Size"
|
||||
"node.text3.input.outline_enabled_in": "Outline"
|
||||
"node.text3.input.outline_color_in": "Outline Color"
|
||||
"node.text3.input.outline_width_in": "Outline Width"
|
||||
"node.text3.input.glow_enabled_in": "Glow"
|
||||
"node.text3.input.glow_color_in": "Glow Color"
|
||||
"node.text3.input.glow_radius_in": "Glow Radius"
|
||||
"node.text3.input.color_in": "Color"
|
||||
"node.text3.input.valign_in": "Vertical Alignment"
|
||||
"node.text3.input.args_in": "Arguments"
|
||||
"node.mosaicfilter.name": "Mosaic"
|
||||
"node.mosaicfilter.input.tex_in": "Texture"
|
||||
"node.mosaicfilter.input.horiz_in": "Horizontal"
|
||||
"node.mosaicfilter.input.vert_in": "Vertical"
|
||||
"node.crop.name": "Crop"
|
||||
"node.crop.input.tex_in": "Texture"
|
||||
"node.crop.input.left_in": "Left"
|
||||
"node.crop.input.top_in": "Top"
|
||||
"node.crop.input.right_in": "Right"
|
||||
"node.crop.input.bottom_in": "Bottom"
|
||||
"node.crop.input.feather_in": "Feather"
|
||||
"node.value.name": "Value"
|
||||
"node.value.input.type_in": "Type"
|
||||
"node.value.input.value_in": "Value"
|
||||
"node.timeremap.name": "Time Remap"
|
||||
"node.timeremap.input.time_in": "Time"
|
||||
"node.timeremap.input.input_in": "Input"
|
||||
"node.shape.name": "Shape"
|
||||
"node.shape.input.type_in": "Type"
|
||||
"node.shape.input.base_in": "Base"
|
||||
"node.shape.input.pos_in": "Position"
|
||||
"node.shape.input.size_in": "Size"
|
||||
"node.shape.input.color_in": "Color"
|
||||
"node.shape.input.radius_in": "Radius"
|
||||
"node.colordifferencekey.name": "Color Difference Key"
|
||||
"node.colordifferencekey.input.tex_in": "Input"
|
||||
"node.colordifferencekey.input.garbage_in": "Garbage Matte"
|
||||
"node.colordifferencekey.input.core_in": "Core Matte"
|
||||
"node.colordifferencekey.input.color_in": "Key Color"
|
||||
"node.colordifferencekey.input.highlights_in": "Highlights"
|
||||
"node.colordifferencekey.input.shadows_in": "Shadows"
|
||||
"node.colordifferencekey.input.mask_only_in": "Show Mask Only"
|
||||
"node.despill.name": "Despill"
|
||||
"node.despill.input.tex_in": "Input"
|
||||
"node.despill.input.color_in": "Key Color"
|
||||
"node.despill.input.method_in": "Method"
|
||||
"node.despill.input.preserve_luminance_input": "Preserve Luminance"
|
||||
"node.group.name": "Group"
|
||||
"node.opacity.name": "Opacity"
|
||||
"node.opacity.input.tex_in": "Texture"
|
||||
"node.opacity.input.opacity_in": "Opacity"
|
||||
"node.flip.name": "Flip"
|
||||
"node.flip.input.tex_in": "Input"
|
||||
"node.flip.input.horiz_in": "Horizontal"
|
||||
"node.flip.input.vert_in": "Vertical"
|
||||
"node.noise.name": "Noise"
|
||||
"node.noise.input.base_in": "Base"
|
||||
"node.noise.input.strength_in": "Strength"
|
||||
"node.noise.input.color_in": "Color"
|
||||
"node.timeoffset.name": "Time Offset"
|
||||
"node.timeoffset.input.time_in": "Time"
|
||||
"node.timeoffset.input.input_in": "Input"
|
||||
"node.transition.name": "Transition"
|
||||
"node.transition.input.tex_in": "From"
|
||||
"node.transition.input.blend_in": "To"
|
||||
"node.transition.input.progress_in": "Progress"
|
||||
"node.transition.input.type_in": "Type"
|
||||
"node.transitionfx.name": "Transition FX"
|
||||
"node.transitionfx.input.tex_in": "From"
|
||||
"node.transitionfx.input.blend_in": "To"
|
||||
"node.transitionfx.input.progress_in": "Progress"
|
||||
"node.transitionfx.input.color_in": "Color"
|
||||
"node.transitionfx.input.direction_in": "Direction"
|
||||
"node.transitionfx.input.type_in": "Type"
|
||||
"node.cornerpin.name": "Corner Pin"
|
||||
"node.cornerpin.input.tex_in": "Texture"
|
||||
"node.cornerpin.input.perspective_in": "Perspective"
|
||||
"node.cornerpin.input.top_left_in": "Top Left"
|
||||
"node.cornerpin.input.top_right_in": "Top Right"
|
||||
"node.cornerpin.input.bottom_right_in": "Bottom Right"
|
||||
"node.cornerpin.input.bottom_left_in": "Bottom Left"
|
||||
"node.displaytransform.name": "Display Transform"
|
||||
"node.displaytransform.input.tex_in": "Input"
|
||||
"node.displaytransform.input.display_in": "Display"
|
||||
"node.displaytransform.input.view_in": "View"
|
||||
"node.displaytransform.input.dir_in": "Direction"
|
||||
"node.ociogradingtransformlinear.name": "OCIO Color Grading (Linear)"
|
||||
"node.ociogradingtransformlinear.input.tex_in": "Input"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_contrast": "Contrast"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_offset": "Offset"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_exposure": "Exposure"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_saturation": "Saturation"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_pivot": "Pivot"
|
||||
"node.ociogradingtransformlinear.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampWhite": "White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.name": "OCIO Color Grading (Log)"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.tex_in": "Input"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_brightness": "Lift"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_contrast": "Gain"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_gamma": "Gamma"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_saturation": "Saturation"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_pivot": "Pivot"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampWhite": "White Clamp"
|
||||
"node.whitebalance.name": "White Balance"
|
||||
"node.whitebalance.input.tex_in": "Input"
|
||||
"node.whitebalance.input.temperature_in": "Temperature (K)"
|
||||
"node.whitebalance.input.tint_in": "Tint"
|
||||
"node.ociolut.name": "OCIO LUT"
|
||||
"node.ociolut.input.tex_in": "Input"
|
||||
"node.ociolut.input.lut_file_in": "LUT File"
|
||||
"node.ociolut.input.lut_dir_in": "Direction"
|
||||
"node.threewaycolor.name": "Three-Way Color"
|
||||
"node.threewaycolor.input.tex_in": "Input"
|
||||
"node.threewaycolor.input.shadows_color_in": "Shadows"
|
||||
"node.threewaycolor.input.midtones_color_in": "Midtones"
|
||||
"node.threewaycolor.input.highlights_color_in": "Highlights"
|
||||
"node.threewaycolor.input.shadows_amount_in": "Shadows Amount"
|
||||
"node.threewaycolor.input.midtones_amount_in": "Midtones Amount"
|
||||
"node.threewaycolor.input.highlights_amount_in": "Highlights Amount"
|
||||
"node.chromakey.name": "Chroma Key"
|
||||
"node.chromakey.input.tex_in": "Input"
|
||||
"node.chromakey.input.color_key": "Key Color"
|
||||
"node.chromakey.input.lower_tolerance_in": "Lower Tolerance"
|
||||
"node.chromakey.input.upper_tolerance_in": "Upper Tolerance"
|
||||
"node.chromakey.input.garbage_in": "Garbage Matte"
|
||||
"node.chromakey.input.core_in": "Core Matte"
|
||||
"node.chromakey.input.highlights_in": "Highlights"
|
||||
"node.chromakey.input.shadows_in": "Shadows"
|
||||
"node.chromakey.input.invert_in": "Invert Mask"
|
||||
"node.chromakey.input.mask_only_in": "Show Mask Only"
|
||||
"node.mask.name": "Mask"
|
||||
"node.mask.input.base_in": "Texture"
|
||||
"node.mask.input.points_in": "Points"
|
||||
"node.mask.input.color_in": "Color"
|
||||
"node.mask.input.invert_in": "Invert"
|
||||
"node.mask.input.feather_in": "Feather"
|
||||
"node.dropshadow.name": "Drop Shadow"
|
||||
"node.dropshadow.input.tex_in": "Texture"
|
||||
"node.dropshadow.input.color_in": "Color"
|
||||
"node.dropshadow.input.distance_in": "Distance"
|
||||
"node.dropshadow.input.angle_in": "Angle"
|
||||
"node.dropshadow.input.radius_in": "Softness"
|
||||
"node.dropshadow.input.opacity_in": "Opacity"
|
||||
"node.dropshadow.input.fast_in": "Faster (Lower Quality)"
|
||||
"node.timeformat.name": "Time Format"
|
||||
"node.timeformat.input.time_in": "Time"
|
||||
"node.timeformat.input.format_in": "Format"
|
||||
"node.timeformat.input.localtime_in": "Interpret time as local time"
|
||||
"node.wave.name": "Wave"
|
||||
"node.wave.input.tex_in": "Input"
|
||||
"node.wave.input.frequency_in": "Frequency"
|
||||
"node.wave.input.intensity_in": "Intensity"
|
||||
"node.wave.input.evolution_in": "Evolution"
|
||||
"node.wave.input.vertical_in": "Direction"
|
||||
"node.tile.name": "Tile"
|
||||
"node.tile.input.tex_in": "Input"
|
||||
"node.tile.input.scale_in": "Scale"
|
||||
"node.tile.input.position_in": "Position"
|
||||
"node.tile.input.anchor_in": "Anchor"
|
||||
"node.tile.input.mirrorx_in": "Mirror Horizontally"
|
||||
"node.tile.input.mirrory_in": "Mirror Vertically"
|
||||
"node.swirl.name": "Swirl"
|
||||
"node.swirl.input.tex_in": "Input"
|
||||
"node.swirl.input.radius_in": "Radius"
|
||||
"node.swirl.input.angle_in": "Angle"
|
||||
"node.swirl.input.pos_in": "Position"
|
||||
"node.ripple.name": "Ripple"
|
||||
"node.ripple.input.tex_in": "Input"
|
||||
"node.ripple.input.evolution_in": "Evolution"
|
||||
"node.ripple.input.intensity_in": "Intensity"
|
||||
"node.ripple.input.frequency_in": "Frequency"
|
||||
"node.ripple.input.position_in": "Position"
|
||||
"node.ripple.input.stretch_in": "Stretch"
|
||||
"node.colormatrix.name": "Color Matrix"
|
||||
"node.colormatrix.input.tex_in": "Input"
|
||||
"node.colormatrix.input.m0": "R <- R"
|
||||
"node.colormatrix.input.m1": "R <- G"
|
||||
"node.colormatrix.input.m2": "R <- B"
|
||||
"node.colormatrix.input.m3": "R <- A"
|
||||
"node.colormatrix.input.m4": "G <- R"
|
||||
"node.colormatrix.input.m5": "G <- G"
|
||||
"node.colormatrix.input.m6": "G <- B"
|
||||
"node.colormatrix.input.m7": "G <- A"
|
||||
"node.colormatrix.input.m8": "B <- R"
|
||||
"node.colormatrix.input.m9": "B <- G"
|
||||
"node.colormatrix.input.m10": "B <- B"
|
||||
"node.colormatrix.input.m11": "B <- A"
|
||||
"node.colormatrix.input.m12": "A <- R"
|
||||
"node.colormatrix.input.m13": "A <- G"
|
||||
"node.colormatrix.input.m14": "A <- B"
|
||||
"node.colormatrix.input.m15": "A <- A"
|
||||
"node.dilate.name": "Dilate"
|
||||
"node.dilate.input.tex_in": "Input"
|
||||
"node.dilate.input.radius_in": "Radius"
|
||||
"node.edgedetect.name": "Edge Detect"
|
||||
"node.edgedetect.input.tex_in": "Input"
|
||||
"node.edgedetect.input.threshold_in": "Threshold"
|
||||
"node.erode.name": "Erode"
|
||||
"node.erode.input.tex_in": "Input"
|
||||
"node.erode.input.radius_in": "Radius"
|
||||
"node.colorcorrect.name": "Color Correct"
|
||||
"node.colorcorrect.input.tex_in": "Input"
|
||||
"node.colorcorrect.input.saturation_in": "Saturation"
|
||||
"node.colorcorrect.input.contrast_in": "Contrast"
|
||||
"node.colorcorrect.input.gamma_in": "Gamma"
|
||||
"node.colorcorrect.input.gain_in": "Gain"
|
||||
"node.colorcorrect.input.offset_in": "Offset"
|
||||
"node.gamma.name": "Gamma"
|
||||
"node.gamma.input.tex_in": "Input"
|
||||
"node.gamma.input.gamma_in": "Gamma"
|
||||
"node.saturation.name": "Saturation"
|
||||
"node.saturation.input.tex_in": "Input"
|
||||
"node.saturation.input.saturation_in": "Saturation"
|
||||
"node.invert.name": "Invert"
|
||||
"node.invert.input.tex_in": "Input"
|
||||
"node.invert.input.invert_r_in": "Invert Red"
|
||||
"node.invert.input.invert_g_in": "Invert Green"
|
||||
"node.invert.input.invert_b_in": "Invert Blue"
|
||||
"node.invert.input.invert_a_in": "Invert Alpha"
|
||||
"node.clamp.name": "Clamp"
|
||||
"node.clamp.input.tex_in": "Input"
|
||||
"node.clamp.input.min_in": "Min"
|
||||
"node.clamp.input.max_in": "Max"
|
||||
"node.grade.name": "Grade"
|
||||
"node.grade.input.tex_in": "Input"
|
||||
"node.grade.input.blackpoint_in": "Black Point"
|
||||
"node.grade.input.whitepoint_in": "White Point"
|
||||
"node.grade.input.black_in": "Black Output"
|
||||
"node.grade.input.white_in": "White Output"
|
||||
"node.grade.input.gamma_in": "Gamma"
|
||||
"node.dirblur.name": "Directional Blur"
|
||||
"node.dirblur.input.tex_in": "Input"
|
||||
"node.dirblur.input.amount_in": "Amount"
|
||||
"node.dirblur.input.angle_in": "Angle"
|
||||
"node.sharpen.name": "Sharpen"
|
||||
"node.sharpen.input.tex_in": "Input"
|
||||
"node.sharpen.input.amount_in": "Amount"
|
||||
"node.dissolve.name": "Dissolve"
|
||||
"node.dissolve.input.tex_in": "Input"
|
||||
"node.dissolve.input.blend_in": "Blend"
|
||||
"node.dissolve.input.mix_in": "Mix"
|
||||
"node.keymix.name": "KeyMix"
|
||||
"node.keymix.input.tex_in": "Input"
|
||||
"node.keymix.input.blend_in": "Blend"
|
||||
"node.keymix.input.mask_in": "Mask"
|
||||
"node.premult.name": "Premultiply"
|
||||
"node.premult.input.tex_in": "Input"
|
||||
"node.premult.input.premult_channel_in": "Channel"
|
||||
"node.unpremult.name": "Unpremultiply"
|
||||
"node.unpremult.input.tex_in": "Input"
|
||||
"node.unpremult.input.unpremult_channel_in": "Channel"
|
||||
"node.position.name": "Position"
|
||||
"node.position.input.tex_in": "Input"
|
||||
"node.position.input.offset_in": "Translate"
|
||||
"node.mirror.name": "Mirror"
|
||||
"node.mirror.input.tex_in": "Input"
|
||||
"node.mirror.input.horizontal_in": "Horizontal (flop)"
|
||||
"node.mirror.input.vertical_in": "Vertical (flip)"
|
||||
"node.checkerboard.name": "Checkerboard"
|
||||
"node.checkerboard.input.base_in": "Base"
|
||||
"node.checkerboard.input.size_in": "Box Size"
|
||||
"node.checkerboard.input.color1_in": "Color 1"
|
||||
"node.checkerboard.input.color2_in": "Color 2"
|
||||
"node.colorbars.name": "Color Bars"
|
||||
"node.colorbars.input.base_in": "Base"
|
||||
"node.colorbars.input.standard_in": "Standard"
|
||||
"node.ramp.name": "Ramp"
|
||||
"node.ramp.input.base_in": "Base"
|
||||
"node.ramp.input.point0_in": "Point 0"
|
||||
"node.ramp.input.point1_in": "Point 1"
|
||||
"node.ramp.input.color0_in": "Color 0"
|
||||
"node.ramp.input.color1_in": "Color 1"
|
||||
"node.multicam.name": "Multi-Cam"
|
||||
"node.multicam.input.current_in": "Current"
|
||||
"node.multicam.input.sources_in": "Sources"
|
||||
"node.multicam.input.sequence_in": "Sequence"
|
||||
"node.multicam.input.sequence_type_in": "Sequence Type"
|
||||
"status.ready": "Prêt"
|
||||
"status.cache": "Cache : activé"
|
||||
"status.proxy.on": "Proxy : activé"
|
||||
@@ -481,6 +855,8 @@
|
||||
"node.category.group": "Groupe"
|
||||
"project.context.new": "Nouveau"
|
||||
"project.new_sequence": "Nouvelle séquence"
|
||||
"project.add_text_footage": "Add Text Footage"
|
||||
"timeline.context.add_adjustment_layer": "Add Adjustment Layer"
|
||||
"project.context.reveal_in_finder": "Révéler dans le Finder"
|
||||
"project.context.replace_footage": "Remplacer les médias"
|
||||
"project.context.rename": "Renommer"
|
||||
|
||||
@@ -171,6 +171,380 @@
|
||||
"multicam.switch_9": "カメラ9に切り替え"
|
||||
"effect_library.hint": "ダブルクリックで選択中のクリップに追加します"
|
||||
"effect_library.group.builtin": "内蔵"
|
||||
"effect_library.group.color": "Color"
|
||||
"effect_library.group.filter": "Filter"
|
||||
"effect_library.group.distort": "Distort"
|
||||
"effect_library.group.keying": "Keying"
|
||||
"effect_library.group.generator": "Generator"
|
||||
"effect_library.group.math": "Math"
|
||||
"effect_library.group.time": "Time"
|
||||
"effect_library.group.channel": "Channel"
|
||||
"effect_library.group.transition": "Transition"
|
||||
"effect_library.group.general": "General"
|
||||
# Built-in effect (node) and effect-parameter (input) names. The effect
|
||||
# library and the inspector build these keys at runtime as
|
||||
# `node.<type-id suffix>.name` and `node.<type-id suffix>.input.<input id>`.
|
||||
"node.polygon.name": "Polygon"
|
||||
"node.polygon.input.base_in": "Base"
|
||||
"node.polygon.input.points_in": "Points"
|
||||
"node.polygon.input.color_in": "Color"
|
||||
"node.ortho.name": "Orthographic Matrix"
|
||||
"node.ortho.input.pos_in": "Position"
|
||||
"node.ortho.input.rot_in": "Rotation"
|
||||
"node.ortho.input.scale_in": "Scale"
|
||||
"node.ortho.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.ortho.input.anchor_in": "Anchor Point"
|
||||
"node.transform.name": "Transform"
|
||||
"node.transform.input.tex_in": "Texture"
|
||||
"node.transform.input.parent_in": "Parent"
|
||||
"node.transform.input.autoscale_in": "Auto-Scale"
|
||||
"node.transform.input.interpolation_in": "Interpolation"
|
||||
"node.transform.input.pos_in": "Position"
|
||||
"node.transform.input.rot_in": "Rotation"
|
||||
"node.transform.input.scale_in": "Scale"
|
||||
"node.transform.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.transform.input.anchor_in": "Anchor Point"
|
||||
"node.volume.name": "Volume"
|
||||
"node.volume.input.samples_in": "Samples"
|
||||
"node.volume.input.volume_in": "Volume"
|
||||
"node.pan.name": "Pan"
|
||||
"node.pan.input.samples_in": "Samples"
|
||||
"node.pan.input.panning_in": "Pan"
|
||||
"node.math.name": "Math"
|
||||
"node.math.input.method_in": "Method"
|
||||
"node.math.input.param_a_in": "Value"
|
||||
"node.math.input.param_b_in": "Value"
|
||||
"node.time.name": "Time"
|
||||
"node.time.input.enabled_in": "Enabled"
|
||||
"node.trigonometry.name": "Trigonometry"
|
||||
"node.trigonometry.input.method_in": "Method"
|
||||
"node.trigonometry.input.x_in": "Value"
|
||||
"node.blur.name": "Blur"
|
||||
"node.blur.input.tex_in": "Input"
|
||||
"node.blur.input.method_in": "Method"
|
||||
"node.blur.input.radius_in": "Radius"
|
||||
"node.blur.input.horiz_in": "Horizontal"
|
||||
"node.blur.input.vert_in": "Vertical"
|
||||
"node.blur.input.directional_degrees_in": "Direction"
|
||||
"node.blur.input.radial_center_in": "Center"
|
||||
"node.blur.input.repeat_edge_pixels_in": "Repeat Edge Pixels"
|
||||
"node.solidgenerator.name": "Solid"
|
||||
"node.solidgenerator.input.color_in": "Color"
|
||||
"node.merge.name": "Merge"
|
||||
"node.merge.input.base_in": "Base"
|
||||
"node.merge.input.blend_in": "Blend"
|
||||
"node.stroke.name": "Stroke"
|
||||
"node.stroke.input.tex_in": "Input"
|
||||
"node.stroke.input.color_in": "Color"
|
||||
"node.stroke.input.radius_in": "Radius"
|
||||
"node.stroke.input.opacity_in": "Opacity"
|
||||
"node.stroke.input.inner_in": "Inner"
|
||||
"node.textgenerator.name": "Text (Legacy)"
|
||||
"node.textgenerator.input.text_in": "Text"
|
||||
"node.textgenerator.input.html_in": "Enable HTML"
|
||||
"node.textgenerator.input.color_in": "Color"
|
||||
"node.textgenerator.input.valign_in": "Vertical Align"
|
||||
"node.textgenerator.input.font_in": "Font"
|
||||
"node.textgenerator.input.font_size_in": "Font Size"
|
||||
"node.text2.name": "Text (Legacy)"
|
||||
"node.text2.input.base_in": "Base"
|
||||
"node.text2.input.pos_in": "Position"
|
||||
"node.text2.input.size_in": "Size"
|
||||
"node.text2.input.color_in": "Color"
|
||||
"node.text2.input.text_in": "Text"
|
||||
"node.text2.input.html_in": "Enable HTML"
|
||||
"node.text2.input.valign_in": "Vertical Align"
|
||||
"node.text2.input.font_in": "Font"
|
||||
"node.text2.input.font_size_in": "Font Size"
|
||||
"node.text3.name": "Text"
|
||||
"node.text3.input.base_in": "Base"
|
||||
"node.text3.input.pos_in": "Position"
|
||||
"node.text3.input.size_in": "Size"
|
||||
"node.text3.input.plain_text_in": "Text"
|
||||
"node.text3.input.text_in": "Text"
|
||||
"node.text3.input.font_family_in": "Font Family"
|
||||
"node.text3.input.font_size_in": "Font Size"
|
||||
"node.text3.input.outline_enabled_in": "Outline"
|
||||
"node.text3.input.outline_color_in": "Outline Color"
|
||||
"node.text3.input.outline_width_in": "Outline Width"
|
||||
"node.text3.input.glow_enabled_in": "Glow"
|
||||
"node.text3.input.glow_color_in": "Glow Color"
|
||||
"node.text3.input.glow_radius_in": "Glow Radius"
|
||||
"node.text3.input.color_in": "Color"
|
||||
"node.text3.input.valign_in": "Vertical Alignment"
|
||||
"node.text3.input.args_in": "Arguments"
|
||||
"node.mosaicfilter.name": "Mosaic"
|
||||
"node.mosaicfilter.input.tex_in": "Texture"
|
||||
"node.mosaicfilter.input.horiz_in": "Horizontal"
|
||||
"node.mosaicfilter.input.vert_in": "Vertical"
|
||||
"node.crop.name": "Crop"
|
||||
"node.crop.input.tex_in": "Texture"
|
||||
"node.crop.input.left_in": "Left"
|
||||
"node.crop.input.top_in": "Top"
|
||||
"node.crop.input.right_in": "Right"
|
||||
"node.crop.input.bottom_in": "Bottom"
|
||||
"node.crop.input.feather_in": "Feather"
|
||||
"node.value.name": "Value"
|
||||
"node.value.input.type_in": "Type"
|
||||
"node.value.input.value_in": "Value"
|
||||
"node.timeremap.name": "Time Remap"
|
||||
"node.timeremap.input.time_in": "Time"
|
||||
"node.timeremap.input.input_in": "Input"
|
||||
"node.shape.name": "Shape"
|
||||
"node.shape.input.type_in": "Type"
|
||||
"node.shape.input.base_in": "Base"
|
||||
"node.shape.input.pos_in": "Position"
|
||||
"node.shape.input.size_in": "Size"
|
||||
"node.shape.input.color_in": "Color"
|
||||
"node.shape.input.radius_in": "Radius"
|
||||
"node.colordifferencekey.name": "Color Difference Key"
|
||||
"node.colordifferencekey.input.tex_in": "Input"
|
||||
"node.colordifferencekey.input.garbage_in": "Garbage Matte"
|
||||
"node.colordifferencekey.input.core_in": "Core Matte"
|
||||
"node.colordifferencekey.input.color_in": "Key Color"
|
||||
"node.colordifferencekey.input.highlights_in": "Highlights"
|
||||
"node.colordifferencekey.input.shadows_in": "Shadows"
|
||||
"node.colordifferencekey.input.mask_only_in": "Show Mask Only"
|
||||
"node.despill.name": "Despill"
|
||||
"node.despill.input.tex_in": "Input"
|
||||
"node.despill.input.color_in": "Key Color"
|
||||
"node.despill.input.method_in": "Method"
|
||||
"node.despill.input.preserve_luminance_input": "Preserve Luminance"
|
||||
"node.group.name": "Group"
|
||||
"node.opacity.name": "Opacity"
|
||||
"node.opacity.input.tex_in": "Texture"
|
||||
"node.opacity.input.opacity_in": "Opacity"
|
||||
"node.flip.name": "Flip"
|
||||
"node.flip.input.tex_in": "Input"
|
||||
"node.flip.input.horiz_in": "Horizontal"
|
||||
"node.flip.input.vert_in": "Vertical"
|
||||
"node.noise.name": "Noise"
|
||||
"node.noise.input.base_in": "Base"
|
||||
"node.noise.input.strength_in": "Strength"
|
||||
"node.noise.input.color_in": "Color"
|
||||
"node.timeoffset.name": "Time Offset"
|
||||
"node.timeoffset.input.time_in": "Time"
|
||||
"node.timeoffset.input.input_in": "Input"
|
||||
"node.transition.name": "Transition"
|
||||
"node.transition.input.tex_in": "From"
|
||||
"node.transition.input.blend_in": "To"
|
||||
"node.transition.input.progress_in": "Progress"
|
||||
"node.transition.input.type_in": "Type"
|
||||
"node.transitionfx.name": "Transition FX"
|
||||
"node.transitionfx.input.tex_in": "From"
|
||||
"node.transitionfx.input.blend_in": "To"
|
||||
"node.transitionfx.input.progress_in": "Progress"
|
||||
"node.transitionfx.input.color_in": "Color"
|
||||
"node.transitionfx.input.direction_in": "Direction"
|
||||
"node.transitionfx.input.type_in": "Type"
|
||||
"node.cornerpin.name": "Corner Pin"
|
||||
"node.cornerpin.input.tex_in": "Texture"
|
||||
"node.cornerpin.input.perspective_in": "Perspective"
|
||||
"node.cornerpin.input.top_left_in": "Top Left"
|
||||
"node.cornerpin.input.top_right_in": "Top Right"
|
||||
"node.cornerpin.input.bottom_right_in": "Bottom Right"
|
||||
"node.cornerpin.input.bottom_left_in": "Bottom Left"
|
||||
"node.displaytransform.name": "Display Transform"
|
||||
"node.displaytransform.input.tex_in": "Input"
|
||||
"node.displaytransform.input.display_in": "Display"
|
||||
"node.displaytransform.input.view_in": "View"
|
||||
"node.displaytransform.input.dir_in": "Direction"
|
||||
"node.ociogradingtransformlinear.name": "OCIO Color Grading (Linear)"
|
||||
"node.ociogradingtransformlinear.input.tex_in": "Input"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_contrast": "Contrast"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_offset": "Offset"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_exposure": "Exposure"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_saturation": "Saturation"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_pivot": "Pivot"
|
||||
"node.ociogradingtransformlinear.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampWhite": "White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.name": "OCIO Color Grading (Log)"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.tex_in": "Input"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_brightness": "Lift"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_contrast": "Gain"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_gamma": "Gamma"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_saturation": "Saturation"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_pivot": "Pivot"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampWhite": "White Clamp"
|
||||
"node.whitebalance.name": "White Balance"
|
||||
"node.whitebalance.input.tex_in": "Input"
|
||||
"node.whitebalance.input.temperature_in": "Temperature (K)"
|
||||
"node.whitebalance.input.tint_in": "Tint"
|
||||
"node.ociolut.name": "OCIO LUT"
|
||||
"node.ociolut.input.tex_in": "Input"
|
||||
"node.ociolut.input.lut_file_in": "LUT File"
|
||||
"node.ociolut.input.lut_dir_in": "Direction"
|
||||
"node.threewaycolor.name": "Three-Way Color"
|
||||
"node.threewaycolor.input.tex_in": "Input"
|
||||
"node.threewaycolor.input.shadows_color_in": "Shadows"
|
||||
"node.threewaycolor.input.midtones_color_in": "Midtones"
|
||||
"node.threewaycolor.input.highlights_color_in": "Highlights"
|
||||
"node.threewaycolor.input.shadows_amount_in": "Shadows Amount"
|
||||
"node.threewaycolor.input.midtones_amount_in": "Midtones Amount"
|
||||
"node.threewaycolor.input.highlights_amount_in": "Highlights Amount"
|
||||
"node.chromakey.name": "Chroma Key"
|
||||
"node.chromakey.input.tex_in": "Input"
|
||||
"node.chromakey.input.color_key": "Key Color"
|
||||
"node.chromakey.input.lower_tolerance_in": "Lower Tolerance"
|
||||
"node.chromakey.input.upper_tolerance_in": "Upper Tolerance"
|
||||
"node.chromakey.input.garbage_in": "Garbage Matte"
|
||||
"node.chromakey.input.core_in": "Core Matte"
|
||||
"node.chromakey.input.highlights_in": "Highlights"
|
||||
"node.chromakey.input.shadows_in": "Shadows"
|
||||
"node.chromakey.input.invert_in": "Invert Mask"
|
||||
"node.chromakey.input.mask_only_in": "Show Mask Only"
|
||||
"node.mask.name": "Mask"
|
||||
"node.mask.input.base_in": "Texture"
|
||||
"node.mask.input.points_in": "Points"
|
||||
"node.mask.input.color_in": "Color"
|
||||
"node.mask.input.invert_in": "Invert"
|
||||
"node.mask.input.feather_in": "Feather"
|
||||
"node.dropshadow.name": "Drop Shadow"
|
||||
"node.dropshadow.input.tex_in": "Texture"
|
||||
"node.dropshadow.input.color_in": "Color"
|
||||
"node.dropshadow.input.distance_in": "Distance"
|
||||
"node.dropshadow.input.angle_in": "Angle"
|
||||
"node.dropshadow.input.radius_in": "Softness"
|
||||
"node.dropshadow.input.opacity_in": "Opacity"
|
||||
"node.dropshadow.input.fast_in": "Faster (Lower Quality)"
|
||||
"node.timeformat.name": "Time Format"
|
||||
"node.timeformat.input.time_in": "Time"
|
||||
"node.timeformat.input.format_in": "Format"
|
||||
"node.timeformat.input.localtime_in": "Interpret time as local time"
|
||||
"node.wave.name": "Wave"
|
||||
"node.wave.input.tex_in": "Input"
|
||||
"node.wave.input.frequency_in": "Frequency"
|
||||
"node.wave.input.intensity_in": "Intensity"
|
||||
"node.wave.input.evolution_in": "Evolution"
|
||||
"node.wave.input.vertical_in": "Direction"
|
||||
"node.tile.name": "Tile"
|
||||
"node.tile.input.tex_in": "Input"
|
||||
"node.tile.input.scale_in": "Scale"
|
||||
"node.tile.input.position_in": "Position"
|
||||
"node.tile.input.anchor_in": "Anchor"
|
||||
"node.tile.input.mirrorx_in": "Mirror Horizontally"
|
||||
"node.tile.input.mirrory_in": "Mirror Vertically"
|
||||
"node.swirl.name": "Swirl"
|
||||
"node.swirl.input.tex_in": "Input"
|
||||
"node.swirl.input.radius_in": "Radius"
|
||||
"node.swirl.input.angle_in": "Angle"
|
||||
"node.swirl.input.pos_in": "Position"
|
||||
"node.ripple.name": "Ripple"
|
||||
"node.ripple.input.tex_in": "Input"
|
||||
"node.ripple.input.evolution_in": "Evolution"
|
||||
"node.ripple.input.intensity_in": "Intensity"
|
||||
"node.ripple.input.frequency_in": "Frequency"
|
||||
"node.ripple.input.position_in": "Position"
|
||||
"node.ripple.input.stretch_in": "Stretch"
|
||||
"node.colormatrix.name": "Color Matrix"
|
||||
"node.colormatrix.input.tex_in": "Input"
|
||||
"node.colormatrix.input.m0": "R <- R"
|
||||
"node.colormatrix.input.m1": "R <- G"
|
||||
"node.colormatrix.input.m2": "R <- B"
|
||||
"node.colormatrix.input.m3": "R <- A"
|
||||
"node.colormatrix.input.m4": "G <- R"
|
||||
"node.colormatrix.input.m5": "G <- G"
|
||||
"node.colormatrix.input.m6": "G <- B"
|
||||
"node.colormatrix.input.m7": "G <- A"
|
||||
"node.colormatrix.input.m8": "B <- R"
|
||||
"node.colormatrix.input.m9": "B <- G"
|
||||
"node.colormatrix.input.m10": "B <- B"
|
||||
"node.colormatrix.input.m11": "B <- A"
|
||||
"node.colormatrix.input.m12": "A <- R"
|
||||
"node.colormatrix.input.m13": "A <- G"
|
||||
"node.colormatrix.input.m14": "A <- B"
|
||||
"node.colormatrix.input.m15": "A <- A"
|
||||
"node.dilate.name": "Dilate"
|
||||
"node.dilate.input.tex_in": "Input"
|
||||
"node.dilate.input.radius_in": "Radius"
|
||||
"node.edgedetect.name": "Edge Detect"
|
||||
"node.edgedetect.input.tex_in": "Input"
|
||||
"node.edgedetect.input.threshold_in": "Threshold"
|
||||
"node.erode.name": "Erode"
|
||||
"node.erode.input.tex_in": "Input"
|
||||
"node.erode.input.radius_in": "Radius"
|
||||
"node.colorcorrect.name": "Color Correct"
|
||||
"node.colorcorrect.input.tex_in": "Input"
|
||||
"node.colorcorrect.input.saturation_in": "Saturation"
|
||||
"node.colorcorrect.input.contrast_in": "Contrast"
|
||||
"node.colorcorrect.input.gamma_in": "Gamma"
|
||||
"node.colorcorrect.input.gain_in": "Gain"
|
||||
"node.colorcorrect.input.offset_in": "Offset"
|
||||
"node.gamma.name": "Gamma"
|
||||
"node.gamma.input.tex_in": "Input"
|
||||
"node.gamma.input.gamma_in": "Gamma"
|
||||
"node.saturation.name": "Saturation"
|
||||
"node.saturation.input.tex_in": "Input"
|
||||
"node.saturation.input.saturation_in": "Saturation"
|
||||
"node.invert.name": "Invert"
|
||||
"node.invert.input.tex_in": "Input"
|
||||
"node.invert.input.invert_r_in": "Invert Red"
|
||||
"node.invert.input.invert_g_in": "Invert Green"
|
||||
"node.invert.input.invert_b_in": "Invert Blue"
|
||||
"node.invert.input.invert_a_in": "Invert Alpha"
|
||||
"node.clamp.name": "Clamp"
|
||||
"node.clamp.input.tex_in": "Input"
|
||||
"node.clamp.input.min_in": "Min"
|
||||
"node.clamp.input.max_in": "Max"
|
||||
"node.grade.name": "Grade"
|
||||
"node.grade.input.tex_in": "Input"
|
||||
"node.grade.input.blackpoint_in": "Black Point"
|
||||
"node.grade.input.whitepoint_in": "White Point"
|
||||
"node.grade.input.black_in": "Black Output"
|
||||
"node.grade.input.white_in": "White Output"
|
||||
"node.grade.input.gamma_in": "Gamma"
|
||||
"node.dirblur.name": "Directional Blur"
|
||||
"node.dirblur.input.tex_in": "Input"
|
||||
"node.dirblur.input.amount_in": "Amount"
|
||||
"node.dirblur.input.angle_in": "Angle"
|
||||
"node.sharpen.name": "Sharpen"
|
||||
"node.sharpen.input.tex_in": "Input"
|
||||
"node.sharpen.input.amount_in": "Amount"
|
||||
"node.dissolve.name": "Dissolve"
|
||||
"node.dissolve.input.tex_in": "Input"
|
||||
"node.dissolve.input.blend_in": "Blend"
|
||||
"node.dissolve.input.mix_in": "Mix"
|
||||
"node.keymix.name": "KeyMix"
|
||||
"node.keymix.input.tex_in": "Input"
|
||||
"node.keymix.input.blend_in": "Blend"
|
||||
"node.keymix.input.mask_in": "Mask"
|
||||
"node.premult.name": "Premultiply"
|
||||
"node.premult.input.tex_in": "Input"
|
||||
"node.premult.input.premult_channel_in": "Channel"
|
||||
"node.unpremult.name": "Unpremultiply"
|
||||
"node.unpremult.input.tex_in": "Input"
|
||||
"node.unpremult.input.unpremult_channel_in": "Channel"
|
||||
"node.position.name": "Position"
|
||||
"node.position.input.tex_in": "Input"
|
||||
"node.position.input.offset_in": "Translate"
|
||||
"node.mirror.name": "Mirror"
|
||||
"node.mirror.input.tex_in": "Input"
|
||||
"node.mirror.input.horizontal_in": "Horizontal (flop)"
|
||||
"node.mirror.input.vertical_in": "Vertical (flip)"
|
||||
"node.checkerboard.name": "Checkerboard"
|
||||
"node.checkerboard.input.base_in": "Base"
|
||||
"node.checkerboard.input.size_in": "Box Size"
|
||||
"node.checkerboard.input.color1_in": "Color 1"
|
||||
"node.checkerboard.input.color2_in": "Color 2"
|
||||
"node.colorbars.name": "Color Bars"
|
||||
"node.colorbars.input.base_in": "Base"
|
||||
"node.colorbars.input.standard_in": "Standard"
|
||||
"node.ramp.name": "Ramp"
|
||||
"node.ramp.input.base_in": "Base"
|
||||
"node.ramp.input.point0_in": "Point 0"
|
||||
"node.ramp.input.point1_in": "Point 1"
|
||||
"node.ramp.input.color0_in": "Color 0"
|
||||
"node.ramp.input.color1_in": "Color 1"
|
||||
"node.multicam.name": "Multi-Cam"
|
||||
"node.multicam.input.current_in": "Current"
|
||||
"node.multicam.input.sources_in": "Sources"
|
||||
"node.multicam.input.sequence_in": "Sequence"
|
||||
"node.multicam.input.sequence_type_in": "Sequence Type"
|
||||
"status.ready": "準備完了"
|
||||
"status.cache": "キャッシュ:有効"
|
||||
"status.proxy.on": "プロキシ:オン"
|
||||
@@ -481,6 +855,8 @@
|
||||
"node.category.group": "グループ"
|
||||
"project.context.new": "新規"
|
||||
"project.new_sequence": "新規シーケンス"
|
||||
"project.add_text_footage": "Add Text Footage"
|
||||
"timeline.context.add_adjustment_layer": "Add Adjustment Layer"
|
||||
"project.context.reveal_in_finder": "Finderに表示"
|
||||
"project.context.replace_footage": "素材を置き換え"
|
||||
"project.context.rename": "名前を変更"
|
||||
|
||||
@@ -171,6 +171,380 @@
|
||||
"multicam.switch_9": "Alternar para a câmera 9"
|
||||
"effect_library.hint": "Clique duas vezes para adicionar ao clipe selecionado"
|
||||
"effect_library.group.builtin": "Integrados"
|
||||
"effect_library.group.color": "Color"
|
||||
"effect_library.group.filter": "Filter"
|
||||
"effect_library.group.distort": "Distort"
|
||||
"effect_library.group.keying": "Keying"
|
||||
"effect_library.group.generator": "Generator"
|
||||
"effect_library.group.math": "Math"
|
||||
"effect_library.group.time": "Time"
|
||||
"effect_library.group.channel": "Channel"
|
||||
"effect_library.group.transition": "Transition"
|
||||
"effect_library.group.general": "General"
|
||||
# Built-in effect (node) and effect-parameter (input) names. The effect
|
||||
# library and the inspector build these keys at runtime as
|
||||
# `node.<type-id suffix>.name` and `node.<type-id suffix>.input.<input id>`.
|
||||
"node.polygon.name": "Polygon"
|
||||
"node.polygon.input.base_in": "Base"
|
||||
"node.polygon.input.points_in": "Points"
|
||||
"node.polygon.input.color_in": "Color"
|
||||
"node.ortho.name": "Orthographic Matrix"
|
||||
"node.ortho.input.pos_in": "Position"
|
||||
"node.ortho.input.rot_in": "Rotation"
|
||||
"node.ortho.input.scale_in": "Scale"
|
||||
"node.ortho.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.ortho.input.anchor_in": "Anchor Point"
|
||||
"node.transform.name": "Transform"
|
||||
"node.transform.input.tex_in": "Texture"
|
||||
"node.transform.input.parent_in": "Parent"
|
||||
"node.transform.input.autoscale_in": "Auto-Scale"
|
||||
"node.transform.input.interpolation_in": "Interpolation"
|
||||
"node.transform.input.pos_in": "Position"
|
||||
"node.transform.input.rot_in": "Rotation"
|
||||
"node.transform.input.scale_in": "Scale"
|
||||
"node.transform.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.transform.input.anchor_in": "Anchor Point"
|
||||
"node.volume.name": "Volume"
|
||||
"node.volume.input.samples_in": "Samples"
|
||||
"node.volume.input.volume_in": "Volume"
|
||||
"node.pan.name": "Pan"
|
||||
"node.pan.input.samples_in": "Samples"
|
||||
"node.pan.input.panning_in": "Pan"
|
||||
"node.math.name": "Math"
|
||||
"node.math.input.method_in": "Method"
|
||||
"node.math.input.param_a_in": "Value"
|
||||
"node.math.input.param_b_in": "Value"
|
||||
"node.time.name": "Time"
|
||||
"node.time.input.enabled_in": "Enabled"
|
||||
"node.trigonometry.name": "Trigonometry"
|
||||
"node.trigonometry.input.method_in": "Method"
|
||||
"node.trigonometry.input.x_in": "Value"
|
||||
"node.blur.name": "Blur"
|
||||
"node.blur.input.tex_in": "Input"
|
||||
"node.blur.input.method_in": "Method"
|
||||
"node.blur.input.radius_in": "Radius"
|
||||
"node.blur.input.horiz_in": "Horizontal"
|
||||
"node.blur.input.vert_in": "Vertical"
|
||||
"node.blur.input.directional_degrees_in": "Direction"
|
||||
"node.blur.input.radial_center_in": "Center"
|
||||
"node.blur.input.repeat_edge_pixels_in": "Repeat Edge Pixels"
|
||||
"node.solidgenerator.name": "Solid"
|
||||
"node.solidgenerator.input.color_in": "Color"
|
||||
"node.merge.name": "Merge"
|
||||
"node.merge.input.base_in": "Base"
|
||||
"node.merge.input.blend_in": "Blend"
|
||||
"node.stroke.name": "Stroke"
|
||||
"node.stroke.input.tex_in": "Input"
|
||||
"node.stroke.input.color_in": "Color"
|
||||
"node.stroke.input.radius_in": "Radius"
|
||||
"node.stroke.input.opacity_in": "Opacity"
|
||||
"node.stroke.input.inner_in": "Inner"
|
||||
"node.textgenerator.name": "Text (Legacy)"
|
||||
"node.textgenerator.input.text_in": "Text"
|
||||
"node.textgenerator.input.html_in": "Enable HTML"
|
||||
"node.textgenerator.input.color_in": "Color"
|
||||
"node.textgenerator.input.valign_in": "Vertical Align"
|
||||
"node.textgenerator.input.font_in": "Font"
|
||||
"node.textgenerator.input.font_size_in": "Font Size"
|
||||
"node.text2.name": "Text (Legacy)"
|
||||
"node.text2.input.base_in": "Base"
|
||||
"node.text2.input.pos_in": "Position"
|
||||
"node.text2.input.size_in": "Size"
|
||||
"node.text2.input.color_in": "Color"
|
||||
"node.text2.input.text_in": "Text"
|
||||
"node.text2.input.html_in": "Enable HTML"
|
||||
"node.text2.input.valign_in": "Vertical Align"
|
||||
"node.text2.input.font_in": "Font"
|
||||
"node.text2.input.font_size_in": "Font Size"
|
||||
"node.text3.name": "Text"
|
||||
"node.text3.input.base_in": "Base"
|
||||
"node.text3.input.pos_in": "Position"
|
||||
"node.text3.input.size_in": "Size"
|
||||
"node.text3.input.plain_text_in": "Text"
|
||||
"node.text3.input.text_in": "Text"
|
||||
"node.text3.input.font_family_in": "Font Family"
|
||||
"node.text3.input.font_size_in": "Font Size"
|
||||
"node.text3.input.outline_enabled_in": "Outline"
|
||||
"node.text3.input.outline_color_in": "Outline Color"
|
||||
"node.text3.input.outline_width_in": "Outline Width"
|
||||
"node.text3.input.glow_enabled_in": "Glow"
|
||||
"node.text3.input.glow_color_in": "Glow Color"
|
||||
"node.text3.input.glow_radius_in": "Glow Radius"
|
||||
"node.text3.input.color_in": "Color"
|
||||
"node.text3.input.valign_in": "Vertical Alignment"
|
||||
"node.text3.input.args_in": "Arguments"
|
||||
"node.mosaicfilter.name": "Mosaic"
|
||||
"node.mosaicfilter.input.tex_in": "Texture"
|
||||
"node.mosaicfilter.input.horiz_in": "Horizontal"
|
||||
"node.mosaicfilter.input.vert_in": "Vertical"
|
||||
"node.crop.name": "Crop"
|
||||
"node.crop.input.tex_in": "Texture"
|
||||
"node.crop.input.left_in": "Left"
|
||||
"node.crop.input.top_in": "Top"
|
||||
"node.crop.input.right_in": "Right"
|
||||
"node.crop.input.bottom_in": "Bottom"
|
||||
"node.crop.input.feather_in": "Feather"
|
||||
"node.value.name": "Value"
|
||||
"node.value.input.type_in": "Type"
|
||||
"node.value.input.value_in": "Value"
|
||||
"node.timeremap.name": "Time Remap"
|
||||
"node.timeremap.input.time_in": "Time"
|
||||
"node.timeremap.input.input_in": "Input"
|
||||
"node.shape.name": "Shape"
|
||||
"node.shape.input.type_in": "Type"
|
||||
"node.shape.input.base_in": "Base"
|
||||
"node.shape.input.pos_in": "Position"
|
||||
"node.shape.input.size_in": "Size"
|
||||
"node.shape.input.color_in": "Color"
|
||||
"node.shape.input.radius_in": "Radius"
|
||||
"node.colordifferencekey.name": "Color Difference Key"
|
||||
"node.colordifferencekey.input.tex_in": "Input"
|
||||
"node.colordifferencekey.input.garbage_in": "Garbage Matte"
|
||||
"node.colordifferencekey.input.core_in": "Core Matte"
|
||||
"node.colordifferencekey.input.color_in": "Key Color"
|
||||
"node.colordifferencekey.input.highlights_in": "Highlights"
|
||||
"node.colordifferencekey.input.shadows_in": "Shadows"
|
||||
"node.colordifferencekey.input.mask_only_in": "Show Mask Only"
|
||||
"node.despill.name": "Despill"
|
||||
"node.despill.input.tex_in": "Input"
|
||||
"node.despill.input.color_in": "Key Color"
|
||||
"node.despill.input.method_in": "Method"
|
||||
"node.despill.input.preserve_luminance_input": "Preserve Luminance"
|
||||
"node.group.name": "Group"
|
||||
"node.opacity.name": "Opacity"
|
||||
"node.opacity.input.tex_in": "Texture"
|
||||
"node.opacity.input.opacity_in": "Opacity"
|
||||
"node.flip.name": "Flip"
|
||||
"node.flip.input.tex_in": "Input"
|
||||
"node.flip.input.horiz_in": "Horizontal"
|
||||
"node.flip.input.vert_in": "Vertical"
|
||||
"node.noise.name": "Noise"
|
||||
"node.noise.input.base_in": "Base"
|
||||
"node.noise.input.strength_in": "Strength"
|
||||
"node.noise.input.color_in": "Color"
|
||||
"node.timeoffset.name": "Time Offset"
|
||||
"node.timeoffset.input.time_in": "Time"
|
||||
"node.timeoffset.input.input_in": "Input"
|
||||
"node.transition.name": "Transition"
|
||||
"node.transition.input.tex_in": "From"
|
||||
"node.transition.input.blend_in": "To"
|
||||
"node.transition.input.progress_in": "Progress"
|
||||
"node.transition.input.type_in": "Type"
|
||||
"node.transitionfx.name": "Transition FX"
|
||||
"node.transitionfx.input.tex_in": "From"
|
||||
"node.transitionfx.input.blend_in": "To"
|
||||
"node.transitionfx.input.progress_in": "Progress"
|
||||
"node.transitionfx.input.color_in": "Color"
|
||||
"node.transitionfx.input.direction_in": "Direction"
|
||||
"node.transitionfx.input.type_in": "Type"
|
||||
"node.cornerpin.name": "Corner Pin"
|
||||
"node.cornerpin.input.tex_in": "Texture"
|
||||
"node.cornerpin.input.perspective_in": "Perspective"
|
||||
"node.cornerpin.input.top_left_in": "Top Left"
|
||||
"node.cornerpin.input.top_right_in": "Top Right"
|
||||
"node.cornerpin.input.bottom_right_in": "Bottom Right"
|
||||
"node.cornerpin.input.bottom_left_in": "Bottom Left"
|
||||
"node.displaytransform.name": "Display Transform"
|
||||
"node.displaytransform.input.tex_in": "Input"
|
||||
"node.displaytransform.input.display_in": "Display"
|
||||
"node.displaytransform.input.view_in": "View"
|
||||
"node.displaytransform.input.dir_in": "Direction"
|
||||
"node.ociogradingtransformlinear.name": "OCIO Color Grading (Linear)"
|
||||
"node.ociogradingtransformlinear.input.tex_in": "Input"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_contrast": "Contrast"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_offset": "Offset"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_exposure": "Exposure"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_saturation": "Saturation"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_pivot": "Pivot"
|
||||
"node.ociogradingtransformlinear.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampWhite": "White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.name": "OCIO Color Grading (Log)"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.tex_in": "Input"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_brightness": "Lift"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_contrast": "Gain"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_gamma": "Gamma"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_saturation": "Saturation"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_pivot": "Pivot"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampWhite": "White Clamp"
|
||||
"node.whitebalance.name": "White Balance"
|
||||
"node.whitebalance.input.tex_in": "Input"
|
||||
"node.whitebalance.input.temperature_in": "Temperature (K)"
|
||||
"node.whitebalance.input.tint_in": "Tint"
|
||||
"node.ociolut.name": "OCIO LUT"
|
||||
"node.ociolut.input.tex_in": "Input"
|
||||
"node.ociolut.input.lut_file_in": "LUT File"
|
||||
"node.ociolut.input.lut_dir_in": "Direction"
|
||||
"node.threewaycolor.name": "Three-Way Color"
|
||||
"node.threewaycolor.input.tex_in": "Input"
|
||||
"node.threewaycolor.input.shadows_color_in": "Shadows"
|
||||
"node.threewaycolor.input.midtones_color_in": "Midtones"
|
||||
"node.threewaycolor.input.highlights_color_in": "Highlights"
|
||||
"node.threewaycolor.input.shadows_amount_in": "Shadows Amount"
|
||||
"node.threewaycolor.input.midtones_amount_in": "Midtones Amount"
|
||||
"node.threewaycolor.input.highlights_amount_in": "Highlights Amount"
|
||||
"node.chromakey.name": "Chroma Key"
|
||||
"node.chromakey.input.tex_in": "Input"
|
||||
"node.chromakey.input.color_key": "Key Color"
|
||||
"node.chromakey.input.lower_tolerance_in": "Lower Tolerance"
|
||||
"node.chromakey.input.upper_tolerance_in": "Upper Tolerance"
|
||||
"node.chromakey.input.garbage_in": "Garbage Matte"
|
||||
"node.chromakey.input.core_in": "Core Matte"
|
||||
"node.chromakey.input.highlights_in": "Highlights"
|
||||
"node.chromakey.input.shadows_in": "Shadows"
|
||||
"node.chromakey.input.invert_in": "Invert Mask"
|
||||
"node.chromakey.input.mask_only_in": "Show Mask Only"
|
||||
"node.mask.name": "Mask"
|
||||
"node.mask.input.base_in": "Texture"
|
||||
"node.mask.input.points_in": "Points"
|
||||
"node.mask.input.color_in": "Color"
|
||||
"node.mask.input.invert_in": "Invert"
|
||||
"node.mask.input.feather_in": "Feather"
|
||||
"node.dropshadow.name": "Drop Shadow"
|
||||
"node.dropshadow.input.tex_in": "Texture"
|
||||
"node.dropshadow.input.color_in": "Color"
|
||||
"node.dropshadow.input.distance_in": "Distance"
|
||||
"node.dropshadow.input.angle_in": "Angle"
|
||||
"node.dropshadow.input.radius_in": "Softness"
|
||||
"node.dropshadow.input.opacity_in": "Opacity"
|
||||
"node.dropshadow.input.fast_in": "Faster (Lower Quality)"
|
||||
"node.timeformat.name": "Time Format"
|
||||
"node.timeformat.input.time_in": "Time"
|
||||
"node.timeformat.input.format_in": "Format"
|
||||
"node.timeformat.input.localtime_in": "Interpret time as local time"
|
||||
"node.wave.name": "Wave"
|
||||
"node.wave.input.tex_in": "Input"
|
||||
"node.wave.input.frequency_in": "Frequency"
|
||||
"node.wave.input.intensity_in": "Intensity"
|
||||
"node.wave.input.evolution_in": "Evolution"
|
||||
"node.wave.input.vertical_in": "Direction"
|
||||
"node.tile.name": "Tile"
|
||||
"node.tile.input.tex_in": "Input"
|
||||
"node.tile.input.scale_in": "Scale"
|
||||
"node.tile.input.position_in": "Position"
|
||||
"node.tile.input.anchor_in": "Anchor"
|
||||
"node.tile.input.mirrorx_in": "Mirror Horizontally"
|
||||
"node.tile.input.mirrory_in": "Mirror Vertically"
|
||||
"node.swirl.name": "Swirl"
|
||||
"node.swirl.input.tex_in": "Input"
|
||||
"node.swirl.input.radius_in": "Radius"
|
||||
"node.swirl.input.angle_in": "Angle"
|
||||
"node.swirl.input.pos_in": "Position"
|
||||
"node.ripple.name": "Ripple"
|
||||
"node.ripple.input.tex_in": "Input"
|
||||
"node.ripple.input.evolution_in": "Evolution"
|
||||
"node.ripple.input.intensity_in": "Intensity"
|
||||
"node.ripple.input.frequency_in": "Frequency"
|
||||
"node.ripple.input.position_in": "Position"
|
||||
"node.ripple.input.stretch_in": "Stretch"
|
||||
"node.colormatrix.name": "Color Matrix"
|
||||
"node.colormatrix.input.tex_in": "Input"
|
||||
"node.colormatrix.input.m0": "R <- R"
|
||||
"node.colormatrix.input.m1": "R <- G"
|
||||
"node.colormatrix.input.m2": "R <- B"
|
||||
"node.colormatrix.input.m3": "R <- A"
|
||||
"node.colormatrix.input.m4": "G <- R"
|
||||
"node.colormatrix.input.m5": "G <- G"
|
||||
"node.colormatrix.input.m6": "G <- B"
|
||||
"node.colormatrix.input.m7": "G <- A"
|
||||
"node.colormatrix.input.m8": "B <- R"
|
||||
"node.colormatrix.input.m9": "B <- G"
|
||||
"node.colormatrix.input.m10": "B <- B"
|
||||
"node.colormatrix.input.m11": "B <- A"
|
||||
"node.colormatrix.input.m12": "A <- R"
|
||||
"node.colormatrix.input.m13": "A <- G"
|
||||
"node.colormatrix.input.m14": "A <- B"
|
||||
"node.colormatrix.input.m15": "A <- A"
|
||||
"node.dilate.name": "Dilate"
|
||||
"node.dilate.input.tex_in": "Input"
|
||||
"node.dilate.input.radius_in": "Radius"
|
||||
"node.edgedetect.name": "Edge Detect"
|
||||
"node.edgedetect.input.tex_in": "Input"
|
||||
"node.edgedetect.input.threshold_in": "Threshold"
|
||||
"node.erode.name": "Erode"
|
||||
"node.erode.input.tex_in": "Input"
|
||||
"node.erode.input.radius_in": "Radius"
|
||||
"node.colorcorrect.name": "Color Correct"
|
||||
"node.colorcorrect.input.tex_in": "Input"
|
||||
"node.colorcorrect.input.saturation_in": "Saturation"
|
||||
"node.colorcorrect.input.contrast_in": "Contrast"
|
||||
"node.colorcorrect.input.gamma_in": "Gamma"
|
||||
"node.colorcorrect.input.gain_in": "Gain"
|
||||
"node.colorcorrect.input.offset_in": "Offset"
|
||||
"node.gamma.name": "Gamma"
|
||||
"node.gamma.input.tex_in": "Input"
|
||||
"node.gamma.input.gamma_in": "Gamma"
|
||||
"node.saturation.name": "Saturation"
|
||||
"node.saturation.input.tex_in": "Input"
|
||||
"node.saturation.input.saturation_in": "Saturation"
|
||||
"node.invert.name": "Invert"
|
||||
"node.invert.input.tex_in": "Input"
|
||||
"node.invert.input.invert_r_in": "Invert Red"
|
||||
"node.invert.input.invert_g_in": "Invert Green"
|
||||
"node.invert.input.invert_b_in": "Invert Blue"
|
||||
"node.invert.input.invert_a_in": "Invert Alpha"
|
||||
"node.clamp.name": "Clamp"
|
||||
"node.clamp.input.tex_in": "Input"
|
||||
"node.clamp.input.min_in": "Min"
|
||||
"node.clamp.input.max_in": "Max"
|
||||
"node.grade.name": "Grade"
|
||||
"node.grade.input.tex_in": "Input"
|
||||
"node.grade.input.blackpoint_in": "Black Point"
|
||||
"node.grade.input.whitepoint_in": "White Point"
|
||||
"node.grade.input.black_in": "Black Output"
|
||||
"node.grade.input.white_in": "White Output"
|
||||
"node.grade.input.gamma_in": "Gamma"
|
||||
"node.dirblur.name": "Directional Blur"
|
||||
"node.dirblur.input.tex_in": "Input"
|
||||
"node.dirblur.input.amount_in": "Amount"
|
||||
"node.dirblur.input.angle_in": "Angle"
|
||||
"node.sharpen.name": "Sharpen"
|
||||
"node.sharpen.input.tex_in": "Input"
|
||||
"node.sharpen.input.amount_in": "Amount"
|
||||
"node.dissolve.name": "Dissolve"
|
||||
"node.dissolve.input.tex_in": "Input"
|
||||
"node.dissolve.input.blend_in": "Blend"
|
||||
"node.dissolve.input.mix_in": "Mix"
|
||||
"node.keymix.name": "KeyMix"
|
||||
"node.keymix.input.tex_in": "Input"
|
||||
"node.keymix.input.blend_in": "Blend"
|
||||
"node.keymix.input.mask_in": "Mask"
|
||||
"node.premult.name": "Premultiply"
|
||||
"node.premult.input.tex_in": "Input"
|
||||
"node.premult.input.premult_channel_in": "Channel"
|
||||
"node.unpremult.name": "Unpremultiply"
|
||||
"node.unpremult.input.tex_in": "Input"
|
||||
"node.unpremult.input.unpremult_channel_in": "Channel"
|
||||
"node.position.name": "Position"
|
||||
"node.position.input.tex_in": "Input"
|
||||
"node.position.input.offset_in": "Translate"
|
||||
"node.mirror.name": "Mirror"
|
||||
"node.mirror.input.tex_in": "Input"
|
||||
"node.mirror.input.horizontal_in": "Horizontal (flop)"
|
||||
"node.mirror.input.vertical_in": "Vertical (flip)"
|
||||
"node.checkerboard.name": "Checkerboard"
|
||||
"node.checkerboard.input.base_in": "Base"
|
||||
"node.checkerboard.input.size_in": "Box Size"
|
||||
"node.checkerboard.input.color1_in": "Color 1"
|
||||
"node.checkerboard.input.color2_in": "Color 2"
|
||||
"node.colorbars.name": "Color Bars"
|
||||
"node.colorbars.input.base_in": "Base"
|
||||
"node.colorbars.input.standard_in": "Standard"
|
||||
"node.ramp.name": "Ramp"
|
||||
"node.ramp.input.base_in": "Base"
|
||||
"node.ramp.input.point0_in": "Point 0"
|
||||
"node.ramp.input.point1_in": "Point 1"
|
||||
"node.ramp.input.color0_in": "Color 0"
|
||||
"node.ramp.input.color1_in": "Color 1"
|
||||
"node.multicam.name": "Multi-Cam"
|
||||
"node.multicam.input.current_in": "Current"
|
||||
"node.multicam.input.sources_in": "Sources"
|
||||
"node.multicam.input.sequence_in": "Sequence"
|
||||
"node.multicam.input.sequence_type_in": "Sequence Type"
|
||||
"status.ready": "Pronto"
|
||||
"status.cache": "Cache: ativado"
|
||||
"status.proxy.on": "Proxy: ativado"
|
||||
@@ -481,6 +855,8 @@
|
||||
"node.category.group": "Grupo"
|
||||
"project.context.new": "Novo"
|
||||
"project.new_sequence": "Nova sequência"
|
||||
"project.add_text_footage": "Add Text Footage"
|
||||
"timeline.context.add_adjustment_layer": "Add Adjustment Layer"
|
||||
"project.context.reveal_in_finder": "Revelar no Finder"
|
||||
"project.context.replace_footage": "Substituir mídia"
|
||||
"project.context.rename": "Renomear"
|
||||
|
||||
@@ -171,6 +171,380 @@
|
||||
"multicam.switch_9": "Переключиться на камеру 9"
|
||||
"effect_library.hint": "Двойной щелчок добавляет эффект к выбранному клипу"
|
||||
"effect_library.group.builtin": "Встроенные"
|
||||
"effect_library.group.color": "Color"
|
||||
"effect_library.group.filter": "Filter"
|
||||
"effect_library.group.distort": "Distort"
|
||||
"effect_library.group.keying": "Keying"
|
||||
"effect_library.group.generator": "Generator"
|
||||
"effect_library.group.math": "Math"
|
||||
"effect_library.group.time": "Time"
|
||||
"effect_library.group.channel": "Channel"
|
||||
"effect_library.group.transition": "Transition"
|
||||
"effect_library.group.general": "General"
|
||||
# Built-in effect (node) and effect-parameter (input) names. The effect
|
||||
# library and the inspector build these keys at runtime as
|
||||
# `node.<type-id suffix>.name` and `node.<type-id suffix>.input.<input id>`.
|
||||
"node.polygon.name": "Polygon"
|
||||
"node.polygon.input.base_in": "Base"
|
||||
"node.polygon.input.points_in": "Points"
|
||||
"node.polygon.input.color_in": "Color"
|
||||
"node.ortho.name": "Orthographic Matrix"
|
||||
"node.ortho.input.pos_in": "Position"
|
||||
"node.ortho.input.rot_in": "Rotation"
|
||||
"node.ortho.input.scale_in": "Scale"
|
||||
"node.ortho.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.ortho.input.anchor_in": "Anchor Point"
|
||||
"node.transform.name": "Transform"
|
||||
"node.transform.input.tex_in": "Texture"
|
||||
"node.transform.input.parent_in": "Parent"
|
||||
"node.transform.input.autoscale_in": "Auto-Scale"
|
||||
"node.transform.input.interpolation_in": "Interpolation"
|
||||
"node.transform.input.pos_in": "Position"
|
||||
"node.transform.input.rot_in": "Rotation"
|
||||
"node.transform.input.scale_in": "Scale"
|
||||
"node.transform.input.uniform_scale_in": "Uniform Scale"
|
||||
"node.transform.input.anchor_in": "Anchor Point"
|
||||
"node.volume.name": "Volume"
|
||||
"node.volume.input.samples_in": "Samples"
|
||||
"node.volume.input.volume_in": "Volume"
|
||||
"node.pan.name": "Pan"
|
||||
"node.pan.input.samples_in": "Samples"
|
||||
"node.pan.input.panning_in": "Pan"
|
||||
"node.math.name": "Math"
|
||||
"node.math.input.method_in": "Method"
|
||||
"node.math.input.param_a_in": "Value"
|
||||
"node.math.input.param_b_in": "Value"
|
||||
"node.time.name": "Time"
|
||||
"node.time.input.enabled_in": "Enabled"
|
||||
"node.trigonometry.name": "Trigonometry"
|
||||
"node.trigonometry.input.method_in": "Method"
|
||||
"node.trigonometry.input.x_in": "Value"
|
||||
"node.blur.name": "Blur"
|
||||
"node.blur.input.tex_in": "Input"
|
||||
"node.blur.input.method_in": "Method"
|
||||
"node.blur.input.radius_in": "Radius"
|
||||
"node.blur.input.horiz_in": "Horizontal"
|
||||
"node.blur.input.vert_in": "Vertical"
|
||||
"node.blur.input.directional_degrees_in": "Direction"
|
||||
"node.blur.input.radial_center_in": "Center"
|
||||
"node.blur.input.repeat_edge_pixels_in": "Repeat Edge Pixels"
|
||||
"node.solidgenerator.name": "Solid"
|
||||
"node.solidgenerator.input.color_in": "Color"
|
||||
"node.merge.name": "Merge"
|
||||
"node.merge.input.base_in": "Base"
|
||||
"node.merge.input.blend_in": "Blend"
|
||||
"node.stroke.name": "Stroke"
|
||||
"node.stroke.input.tex_in": "Input"
|
||||
"node.stroke.input.color_in": "Color"
|
||||
"node.stroke.input.radius_in": "Radius"
|
||||
"node.stroke.input.opacity_in": "Opacity"
|
||||
"node.stroke.input.inner_in": "Inner"
|
||||
"node.textgenerator.name": "Text (Legacy)"
|
||||
"node.textgenerator.input.text_in": "Text"
|
||||
"node.textgenerator.input.html_in": "Enable HTML"
|
||||
"node.textgenerator.input.color_in": "Color"
|
||||
"node.textgenerator.input.valign_in": "Vertical Align"
|
||||
"node.textgenerator.input.font_in": "Font"
|
||||
"node.textgenerator.input.font_size_in": "Font Size"
|
||||
"node.text2.name": "Text (Legacy)"
|
||||
"node.text2.input.base_in": "Base"
|
||||
"node.text2.input.pos_in": "Position"
|
||||
"node.text2.input.size_in": "Size"
|
||||
"node.text2.input.color_in": "Color"
|
||||
"node.text2.input.text_in": "Text"
|
||||
"node.text2.input.html_in": "Enable HTML"
|
||||
"node.text2.input.valign_in": "Vertical Align"
|
||||
"node.text2.input.font_in": "Font"
|
||||
"node.text2.input.font_size_in": "Font Size"
|
||||
"node.text3.name": "Text"
|
||||
"node.text3.input.base_in": "Base"
|
||||
"node.text3.input.pos_in": "Position"
|
||||
"node.text3.input.size_in": "Size"
|
||||
"node.text3.input.plain_text_in": "Text"
|
||||
"node.text3.input.text_in": "Text"
|
||||
"node.text3.input.font_family_in": "Font Family"
|
||||
"node.text3.input.font_size_in": "Font Size"
|
||||
"node.text3.input.outline_enabled_in": "Outline"
|
||||
"node.text3.input.outline_color_in": "Outline Color"
|
||||
"node.text3.input.outline_width_in": "Outline Width"
|
||||
"node.text3.input.glow_enabled_in": "Glow"
|
||||
"node.text3.input.glow_color_in": "Glow Color"
|
||||
"node.text3.input.glow_radius_in": "Glow Radius"
|
||||
"node.text3.input.color_in": "Color"
|
||||
"node.text3.input.valign_in": "Vertical Alignment"
|
||||
"node.text3.input.args_in": "Arguments"
|
||||
"node.mosaicfilter.name": "Mosaic"
|
||||
"node.mosaicfilter.input.tex_in": "Texture"
|
||||
"node.mosaicfilter.input.horiz_in": "Horizontal"
|
||||
"node.mosaicfilter.input.vert_in": "Vertical"
|
||||
"node.crop.name": "Crop"
|
||||
"node.crop.input.tex_in": "Texture"
|
||||
"node.crop.input.left_in": "Left"
|
||||
"node.crop.input.top_in": "Top"
|
||||
"node.crop.input.right_in": "Right"
|
||||
"node.crop.input.bottom_in": "Bottom"
|
||||
"node.crop.input.feather_in": "Feather"
|
||||
"node.value.name": "Value"
|
||||
"node.value.input.type_in": "Type"
|
||||
"node.value.input.value_in": "Value"
|
||||
"node.timeremap.name": "Time Remap"
|
||||
"node.timeremap.input.time_in": "Time"
|
||||
"node.timeremap.input.input_in": "Input"
|
||||
"node.shape.name": "Shape"
|
||||
"node.shape.input.type_in": "Type"
|
||||
"node.shape.input.base_in": "Base"
|
||||
"node.shape.input.pos_in": "Position"
|
||||
"node.shape.input.size_in": "Size"
|
||||
"node.shape.input.color_in": "Color"
|
||||
"node.shape.input.radius_in": "Radius"
|
||||
"node.colordifferencekey.name": "Color Difference Key"
|
||||
"node.colordifferencekey.input.tex_in": "Input"
|
||||
"node.colordifferencekey.input.garbage_in": "Garbage Matte"
|
||||
"node.colordifferencekey.input.core_in": "Core Matte"
|
||||
"node.colordifferencekey.input.color_in": "Key Color"
|
||||
"node.colordifferencekey.input.highlights_in": "Highlights"
|
||||
"node.colordifferencekey.input.shadows_in": "Shadows"
|
||||
"node.colordifferencekey.input.mask_only_in": "Show Mask Only"
|
||||
"node.despill.name": "Despill"
|
||||
"node.despill.input.tex_in": "Input"
|
||||
"node.despill.input.color_in": "Key Color"
|
||||
"node.despill.input.method_in": "Method"
|
||||
"node.despill.input.preserve_luminance_input": "Preserve Luminance"
|
||||
"node.group.name": "Group"
|
||||
"node.opacity.name": "Opacity"
|
||||
"node.opacity.input.tex_in": "Texture"
|
||||
"node.opacity.input.opacity_in": "Opacity"
|
||||
"node.flip.name": "Flip"
|
||||
"node.flip.input.tex_in": "Input"
|
||||
"node.flip.input.horiz_in": "Horizontal"
|
||||
"node.flip.input.vert_in": "Vertical"
|
||||
"node.noise.name": "Noise"
|
||||
"node.noise.input.base_in": "Base"
|
||||
"node.noise.input.strength_in": "Strength"
|
||||
"node.noise.input.color_in": "Color"
|
||||
"node.timeoffset.name": "Time Offset"
|
||||
"node.timeoffset.input.time_in": "Time"
|
||||
"node.timeoffset.input.input_in": "Input"
|
||||
"node.transition.name": "Transition"
|
||||
"node.transition.input.tex_in": "From"
|
||||
"node.transition.input.blend_in": "To"
|
||||
"node.transition.input.progress_in": "Progress"
|
||||
"node.transition.input.type_in": "Type"
|
||||
"node.transitionfx.name": "Transition FX"
|
||||
"node.transitionfx.input.tex_in": "From"
|
||||
"node.transitionfx.input.blend_in": "To"
|
||||
"node.transitionfx.input.progress_in": "Progress"
|
||||
"node.transitionfx.input.color_in": "Color"
|
||||
"node.transitionfx.input.direction_in": "Direction"
|
||||
"node.transitionfx.input.type_in": "Type"
|
||||
"node.cornerpin.name": "Corner Pin"
|
||||
"node.cornerpin.input.tex_in": "Texture"
|
||||
"node.cornerpin.input.perspective_in": "Perspective"
|
||||
"node.cornerpin.input.top_left_in": "Top Left"
|
||||
"node.cornerpin.input.top_right_in": "Top Right"
|
||||
"node.cornerpin.input.bottom_right_in": "Bottom Right"
|
||||
"node.cornerpin.input.bottom_left_in": "Bottom Left"
|
||||
"node.displaytransform.name": "Display Transform"
|
||||
"node.displaytransform.input.tex_in": "Input"
|
||||
"node.displaytransform.input.display_in": "Display"
|
||||
"node.displaytransform.input.view_in": "View"
|
||||
"node.displaytransform.input.dir_in": "Direction"
|
||||
"node.ociogradingtransformlinear.name": "OCIO Color Grading (Linear)"
|
||||
"node.ociogradingtransformlinear.input.tex_in": "Input"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_contrast": "Contrast"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_offset": "Offset"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_exposure": "Exposure"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_saturation": "Saturation"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_pivot": "Pivot"
|
||||
"node.ociogradingtransformlinear.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.ociogradingtransformlinear.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampWhite": "White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.name": "OCIO Color Grading (Log)"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.tex_in": "Input"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_brightness": "Lift"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_contrast": "Gain"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_gamma": "Gamma"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_saturation": "Saturation"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_pivot": "Pivot"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_black_enable_in": "Enable Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampBlack": "Black Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_white_enable_in": "Enable White Clamp"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampWhite": "White Clamp"
|
||||
"node.whitebalance.name": "White Balance"
|
||||
"node.whitebalance.input.tex_in": "Input"
|
||||
"node.whitebalance.input.temperature_in": "Temperature (K)"
|
||||
"node.whitebalance.input.tint_in": "Tint"
|
||||
"node.ociolut.name": "OCIO LUT"
|
||||
"node.ociolut.input.tex_in": "Input"
|
||||
"node.ociolut.input.lut_file_in": "LUT File"
|
||||
"node.ociolut.input.lut_dir_in": "Direction"
|
||||
"node.threewaycolor.name": "Three-Way Color"
|
||||
"node.threewaycolor.input.tex_in": "Input"
|
||||
"node.threewaycolor.input.shadows_color_in": "Shadows"
|
||||
"node.threewaycolor.input.midtones_color_in": "Midtones"
|
||||
"node.threewaycolor.input.highlights_color_in": "Highlights"
|
||||
"node.threewaycolor.input.shadows_amount_in": "Shadows Amount"
|
||||
"node.threewaycolor.input.midtones_amount_in": "Midtones Amount"
|
||||
"node.threewaycolor.input.highlights_amount_in": "Highlights Amount"
|
||||
"node.chromakey.name": "Chroma Key"
|
||||
"node.chromakey.input.tex_in": "Input"
|
||||
"node.chromakey.input.color_key": "Key Color"
|
||||
"node.chromakey.input.lower_tolerance_in": "Lower Tolerance"
|
||||
"node.chromakey.input.upper_tolerance_in": "Upper Tolerance"
|
||||
"node.chromakey.input.garbage_in": "Garbage Matte"
|
||||
"node.chromakey.input.core_in": "Core Matte"
|
||||
"node.chromakey.input.highlights_in": "Highlights"
|
||||
"node.chromakey.input.shadows_in": "Shadows"
|
||||
"node.chromakey.input.invert_in": "Invert Mask"
|
||||
"node.chromakey.input.mask_only_in": "Show Mask Only"
|
||||
"node.mask.name": "Mask"
|
||||
"node.mask.input.base_in": "Texture"
|
||||
"node.mask.input.points_in": "Points"
|
||||
"node.mask.input.color_in": "Color"
|
||||
"node.mask.input.invert_in": "Invert"
|
||||
"node.mask.input.feather_in": "Feather"
|
||||
"node.dropshadow.name": "Drop Shadow"
|
||||
"node.dropshadow.input.tex_in": "Texture"
|
||||
"node.dropshadow.input.color_in": "Color"
|
||||
"node.dropshadow.input.distance_in": "Distance"
|
||||
"node.dropshadow.input.angle_in": "Angle"
|
||||
"node.dropshadow.input.radius_in": "Softness"
|
||||
"node.dropshadow.input.opacity_in": "Opacity"
|
||||
"node.dropshadow.input.fast_in": "Faster (Lower Quality)"
|
||||
"node.timeformat.name": "Time Format"
|
||||
"node.timeformat.input.time_in": "Time"
|
||||
"node.timeformat.input.format_in": "Format"
|
||||
"node.timeformat.input.localtime_in": "Interpret time as local time"
|
||||
"node.wave.name": "Wave"
|
||||
"node.wave.input.tex_in": "Input"
|
||||
"node.wave.input.frequency_in": "Frequency"
|
||||
"node.wave.input.intensity_in": "Intensity"
|
||||
"node.wave.input.evolution_in": "Evolution"
|
||||
"node.wave.input.vertical_in": "Direction"
|
||||
"node.tile.name": "Tile"
|
||||
"node.tile.input.tex_in": "Input"
|
||||
"node.tile.input.scale_in": "Scale"
|
||||
"node.tile.input.position_in": "Position"
|
||||
"node.tile.input.anchor_in": "Anchor"
|
||||
"node.tile.input.mirrorx_in": "Mirror Horizontally"
|
||||
"node.tile.input.mirrory_in": "Mirror Vertically"
|
||||
"node.swirl.name": "Swirl"
|
||||
"node.swirl.input.tex_in": "Input"
|
||||
"node.swirl.input.radius_in": "Radius"
|
||||
"node.swirl.input.angle_in": "Angle"
|
||||
"node.swirl.input.pos_in": "Position"
|
||||
"node.ripple.name": "Ripple"
|
||||
"node.ripple.input.tex_in": "Input"
|
||||
"node.ripple.input.evolution_in": "Evolution"
|
||||
"node.ripple.input.intensity_in": "Intensity"
|
||||
"node.ripple.input.frequency_in": "Frequency"
|
||||
"node.ripple.input.position_in": "Position"
|
||||
"node.ripple.input.stretch_in": "Stretch"
|
||||
"node.colormatrix.name": "Color Matrix"
|
||||
"node.colormatrix.input.tex_in": "Input"
|
||||
"node.colormatrix.input.m0": "R <- R"
|
||||
"node.colormatrix.input.m1": "R <- G"
|
||||
"node.colormatrix.input.m2": "R <- B"
|
||||
"node.colormatrix.input.m3": "R <- A"
|
||||
"node.colormatrix.input.m4": "G <- R"
|
||||
"node.colormatrix.input.m5": "G <- G"
|
||||
"node.colormatrix.input.m6": "G <- B"
|
||||
"node.colormatrix.input.m7": "G <- A"
|
||||
"node.colormatrix.input.m8": "B <- R"
|
||||
"node.colormatrix.input.m9": "B <- G"
|
||||
"node.colormatrix.input.m10": "B <- B"
|
||||
"node.colormatrix.input.m11": "B <- A"
|
||||
"node.colormatrix.input.m12": "A <- R"
|
||||
"node.colormatrix.input.m13": "A <- G"
|
||||
"node.colormatrix.input.m14": "A <- B"
|
||||
"node.colormatrix.input.m15": "A <- A"
|
||||
"node.dilate.name": "Dilate"
|
||||
"node.dilate.input.tex_in": "Input"
|
||||
"node.dilate.input.radius_in": "Radius"
|
||||
"node.edgedetect.name": "Edge Detect"
|
||||
"node.edgedetect.input.tex_in": "Input"
|
||||
"node.edgedetect.input.threshold_in": "Threshold"
|
||||
"node.erode.name": "Erode"
|
||||
"node.erode.input.tex_in": "Input"
|
||||
"node.erode.input.radius_in": "Radius"
|
||||
"node.colorcorrect.name": "Color Correct"
|
||||
"node.colorcorrect.input.tex_in": "Input"
|
||||
"node.colorcorrect.input.saturation_in": "Saturation"
|
||||
"node.colorcorrect.input.contrast_in": "Contrast"
|
||||
"node.colorcorrect.input.gamma_in": "Gamma"
|
||||
"node.colorcorrect.input.gain_in": "Gain"
|
||||
"node.colorcorrect.input.offset_in": "Offset"
|
||||
"node.gamma.name": "Gamma"
|
||||
"node.gamma.input.tex_in": "Input"
|
||||
"node.gamma.input.gamma_in": "Gamma"
|
||||
"node.saturation.name": "Saturation"
|
||||
"node.saturation.input.tex_in": "Input"
|
||||
"node.saturation.input.saturation_in": "Saturation"
|
||||
"node.invert.name": "Invert"
|
||||
"node.invert.input.tex_in": "Input"
|
||||
"node.invert.input.invert_r_in": "Invert Red"
|
||||
"node.invert.input.invert_g_in": "Invert Green"
|
||||
"node.invert.input.invert_b_in": "Invert Blue"
|
||||
"node.invert.input.invert_a_in": "Invert Alpha"
|
||||
"node.clamp.name": "Clamp"
|
||||
"node.clamp.input.tex_in": "Input"
|
||||
"node.clamp.input.min_in": "Min"
|
||||
"node.clamp.input.max_in": "Max"
|
||||
"node.grade.name": "Grade"
|
||||
"node.grade.input.tex_in": "Input"
|
||||
"node.grade.input.blackpoint_in": "Black Point"
|
||||
"node.grade.input.whitepoint_in": "White Point"
|
||||
"node.grade.input.black_in": "Black Output"
|
||||
"node.grade.input.white_in": "White Output"
|
||||
"node.grade.input.gamma_in": "Gamma"
|
||||
"node.dirblur.name": "Directional Blur"
|
||||
"node.dirblur.input.tex_in": "Input"
|
||||
"node.dirblur.input.amount_in": "Amount"
|
||||
"node.dirblur.input.angle_in": "Angle"
|
||||
"node.sharpen.name": "Sharpen"
|
||||
"node.sharpen.input.tex_in": "Input"
|
||||
"node.sharpen.input.amount_in": "Amount"
|
||||
"node.dissolve.name": "Dissolve"
|
||||
"node.dissolve.input.tex_in": "Input"
|
||||
"node.dissolve.input.blend_in": "Blend"
|
||||
"node.dissolve.input.mix_in": "Mix"
|
||||
"node.keymix.name": "KeyMix"
|
||||
"node.keymix.input.tex_in": "Input"
|
||||
"node.keymix.input.blend_in": "Blend"
|
||||
"node.keymix.input.mask_in": "Mask"
|
||||
"node.premult.name": "Premultiply"
|
||||
"node.premult.input.tex_in": "Input"
|
||||
"node.premult.input.premult_channel_in": "Channel"
|
||||
"node.unpremult.name": "Unpremultiply"
|
||||
"node.unpremult.input.tex_in": "Input"
|
||||
"node.unpremult.input.unpremult_channel_in": "Channel"
|
||||
"node.position.name": "Position"
|
||||
"node.position.input.tex_in": "Input"
|
||||
"node.position.input.offset_in": "Translate"
|
||||
"node.mirror.name": "Mirror"
|
||||
"node.mirror.input.tex_in": "Input"
|
||||
"node.mirror.input.horizontal_in": "Horizontal (flop)"
|
||||
"node.mirror.input.vertical_in": "Vertical (flip)"
|
||||
"node.checkerboard.name": "Checkerboard"
|
||||
"node.checkerboard.input.base_in": "Base"
|
||||
"node.checkerboard.input.size_in": "Box Size"
|
||||
"node.checkerboard.input.color1_in": "Color 1"
|
||||
"node.checkerboard.input.color2_in": "Color 2"
|
||||
"node.colorbars.name": "Color Bars"
|
||||
"node.colorbars.input.base_in": "Base"
|
||||
"node.colorbars.input.standard_in": "Standard"
|
||||
"node.ramp.name": "Ramp"
|
||||
"node.ramp.input.base_in": "Base"
|
||||
"node.ramp.input.point0_in": "Point 0"
|
||||
"node.ramp.input.point1_in": "Point 1"
|
||||
"node.ramp.input.color0_in": "Color 0"
|
||||
"node.ramp.input.color1_in": "Color 1"
|
||||
"node.multicam.name": "Multi-Cam"
|
||||
"node.multicam.input.current_in": "Current"
|
||||
"node.multicam.input.sources_in": "Sources"
|
||||
"node.multicam.input.sequence_in": "Sequence"
|
||||
"node.multicam.input.sequence_type_in": "Sequence Type"
|
||||
"status.ready": "Готово"
|
||||
"status.cache": "Кэш: включён"
|
||||
"status.proxy.on": "Прокси: вкл"
|
||||
@@ -481,6 +855,8 @@
|
||||
"node.category.group": "Группа"
|
||||
"project.context.new": "Создать"
|
||||
"project.new_sequence": "Новая последовательность"
|
||||
"project.add_text_footage": "Add Text Footage"
|
||||
"timeline.context.add_adjustment_layer": "Add Adjustment Layer"
|
||||
"project.context.reveal_in_finder": "Показать в Finder"
|
||||
"project.context.replace_footage": "Заменить медиафайлы"
|
||||
"project.context.rename": "Переименовать"
|
||||
|
||||
@@ -169,6 +169,380 @@
|
||||
"multicam.switch_9": "切换到机位 9"
|
||||
"effect_library.hint": "双击添加到选中片段"
|
||||
"effect_library.group.builtin": "内置"
|
||||
"effect_library.group.color": "调色"
|
||||
"effect_library.group.filter": "滤镜"
|
||||
"effect_library.group.distort": "扭曲"
|
||||
"effect_library.group.keying": "键控"
|
||||
"effect_library.group.generator": "生成器"
|
||||
"effect_library.group.math": "数学"
|
||||
"effect_library.group.time": "时间"
|
||||
"effect_library.group.channel": "通道"
|
||||
"effect_library.group.transition": "转场"
|
||||
"effect_library.group.general": "通用"
|
||||
# Built-in effect (node) and effect-parameter (input) names. The effect
|
||||
# library and the inspector build these keys at runtime as
|
||||
# `node.<type-id suffix>.name` and `node.<type-id suffix>.input.<input id>`.
|
||||
"node.polygon.name": "多边形"
|
||||
"node.polygon.input.base_in": "基础"
|
||||
"node.polygon.input.points_in": "顶点"
|
||||
"node.polygon.input.color_in": "颜色"
|
||||
"node.ortho.name": "正交矩阵"
|
||||
"node.ortho.input.pos_in": "位置"
|
||||
"node.ortho.input.rot_in": "旋转"
|
||||
"node.ortho.input.scale_in": "缩放"
|
||||
"node.ortho.input.uniform_scale_in": "等比缩放"
|
||||
"node.ortho.input.anchor_in": "锚点"
|
||||
"node.transform.name": "变换"
|
||||
"node.transform.input.tex_in": "纹理"
|
||||
"node.transform.input.parent_in": "父级"
|
||||
"node.transform.input.autoscale_in": "自动缩放"
|
||||
"node.transform.input.interpolation_in": "插值"
|
||||
"node.transform.input.pos_in": "位置"
|
||||
"node.transform.input.rot_in": "旋转"
|
||||
"node.transform.input.scale_in": "缩放"
|
||||
"node.transform.input.uniform_scale_in": "等比缩放"
|
||||
"node.transform.input.anchor_in": "锚点"
|
||||
"node.volume.name": "音量"
|
||||
"node.volume.input.samples_in": "采样数"
|
||||
"node.volume.input.volume_in": "音量"
|
||||
"node.pan.name": "声像"
|
||||
"node.pan.input.samples_in": "采样数"
|
||||
"node.pan.input.panning_in": "声像"
|
||||
"node.math.name": "数学"
|
||||
"node.math.input.method_in": "方法"
|
||||
"node.math.input.param_a_in": "数值"
|
||||
"node.math.input.param_b_in": "数值"
|
||||
"node.time.name": "时间"
|
||||
"node.time.input.enabled_in": "启用"
|
||||
"node.trigonometry.name": "三角函数"
|
||||
"node.trigonometry.input.method_in": "方法"
|
||||
"node.trigonometry.input.x_in": "数值"
|
||||
"node.blur.name": "模糊"
|
||||
"node.blur.input.tex_in": "输入"
|
||||
"node.blur.input.method_in": "方法"
|
||||
"node.blur.input.radius_in": "半径"
|
||||
"node.blur.input.horiz_in": "水平"
|
||||
"node.blur.input.vert_in": "垂直"
|
||||
"node.blur.input.directional_degrees_in": "方向"
|
||||
"node.blur.input.radial_center_in": "中心"
|
||||
"node.blur.input.repeat_edge_pixels_in": "重复边缘像素"
|
||||
"node.solidgenerator.name": "纯色"
|
||||
"node.solidgenerator.input.color_in": "颜色"
|
||||
"node.merge.name": "合并"
|
||||
"node.merge.input.base_in": "基础"
|
||||
"node.merge.input.blend_in": "混合层"
|
||||
"node.stroke.name": "描边"
|
||||
"node.stroke.input.tex_in": "输入"
|
||||
"node.stroke.input.color_in": "颜色"
|
||||
"node.stroke.input.radius_in": "半径"
|
||||
"node.stroke.input.opacity_in": "不透明度"
|
||||
"node.stroke.input.inner_in": "内部"
|
||||
"node.textgenerator.name": "文本(旧版)"
|
||||
"node.textgenerator.input.text_in": "文本"
|
||||
"node.textgenerator.input.html_in": "启用 HTML"
|
||||
"node.textgenerator.input.color_in": "颜色"
|
||||
"node.textgenerator.input.valign_in": "垂直对齐"
|
||||
"node.textgenerator.input.font_in": "字体"
|
||||
"node.textgenerator.input.font_size_in": "字号"
|
||||
"node.text2.name": "文本(旧版)"
|
||||
"node.text2.input.base_in": "基础"
|
||||
"node.text2.input.pos_in": "位置"
|
||||
"node.text2.input.size_in": "尺寸"
|
||||
"node.text2.input.color_in": "颜色"
|
||||
"node.text2.input.text_in": "文本"
|
||||
"node.text2.input.html_in": "启用 HTML"
|
||||
"node.text2.input.valign_in": "垂直对齐"
|
||||
"node.text2.input.font_in": "字体"
|
||||
"node.text2.input.font_size_in": "字号"
|
||||
"node.text3.name": "文本"
|
||||
"node.text3.input.base_in": "基础"
|
||||
"node.text3.input.pos_in": "位置"
|
||||
"node.text3.input.size_in": "尺寸"
|
||||
"node.text3.input.plain_text_in": "文本"
|
||||
"node.text3.input.text_in": "文本"
|
||||
"node.text3.input.font_family_in": "字体族"
|
||||
"node.text3.input.font_size_in": "字号"
|
||||
"node.text3.input.outline_enabled_in": "描边"
|
||||
"node.text3.input.outline_color_in": "描边颜色"
|
||||
"node.text3.input.outline_width_in": "描边宽度"
|
||||
"node.text3.input.glow_enabled_in": "辉光"
|
||||
"node.text3.input.glow_color_in": "辉光颜色"
|
||||
"node.text3.input.glow_radius_in": "辉光半径"
|
||||
"node.text3.input.color_in": "颜色"
|
||||
"node.text3.input.valign_in": "垂直对齐"
|
||||
"node.text3.input.args_in": "参数列表"
|
||||
"node.mosaicfilter.name": "马赛克"
|
||||
"node.mosaicfilter.input.tex_in": "纹理"
|
||||
"node.mosaicfilter.input.horiz_in": "水平"
|
||||
"node.mosaicfilter.input.vert_in": "垂直"
|
||||
"node.crop.name": "裁剪"
|
||||
"node.crop.input.tex_in": "纹理"
|
||||
"node.crop.input.left_in": "左"
|
||||
"node.crop.input.top_in": "上"
|
||||
"node.crop.input.right_in": "右"
|
||||
"node.crop.input.bottom_in": "下"
|
||||
"node.crop.input.feather_in": "羽化"
|
||||
"node.value.name": "数值"
|
||||
"node.value.input.type_in": "类型"
|
||||
"node.value.input.value_in": "数值"
|
||||
"node.timeremap.name": "时间重映射"
|
||||
"node.timeremap.input.time_in": "时间"
|
||||
"node.timeremap.input.input_in": "输入"
|
||||
"node.shape.name": "形状"
|
||||
"node.shape.input.type_in": "类型"
|
||||
"node.shape.input.base_in": "基础"
|
||||
"node.shape.input.pos_in": "位置"
|
||||
"node.shape.input.size_in": "尺寸"
|
||||
"node.shape.input.color_in": "颜色"
|
||||
"node.shape.input.radius_in": "半径"
|
||||
"node.colordifferencekey.name": "色差键控"
|
||||
"node.colordifferencekey.input.tex_in": "输入"
|
||||
"node.colordifferencekey.input.garbage_in": "垃圾遮罩"
|
||||
"node.colordifferencekey.input.core_in": "核心遮罩"
|
||||
"node.colordifferencekey.input.color_in": "键控色"
|
||||
"node.colordifferencekey.input.highlights_in": "高光"
|
||||
"node.colordifferencekey.input.shadows_in": "阴影"
|
||||
"node.colordifferencekey.input.mask_only_in": "仅显示遮罩"
|
||||
"node.despill.name": "去溢色"
|
||||
"node.despill.input.tex_in": "输入"
|
||||
"node.despill.input.color_in": "键控色"
|
||||
"node.despill.input.method_in": "方法"
|
||||
"node.despill.input.preserve_luminance_input": "保持亮度"
|
||||
"node.group.name": "组"
|
||||
"node.opacity.name": "不透明度"
|
||||
"node.opacity.input.tex_in": "纹理"
|
||||
"node.opacity.input.opacity_in": "不透明度"
|
||||
"node.flip.name": "翻转"
|
||||
"node.flip.input.tex_in": "输入"
|
||||
"node.flip.input.horiz_in": "水平"
|
||||
"node.flip.input.vert_in": "垂直"
|
||||
"node.noise.name": "噪波"
|
||||
"node.noise.input.base_in": "基础"
|
||||
"node.noise.input.strength_in": "强度"
|
||||
"node.noise.input.color_in": "颜色"
|
||||
"node.timeoffset.name": "时间偏移"
|
||||
"node.timeoffset.input.time_in": "时间"
|
||||
"node.timeoffset.input.input_in": "输入"
|
||||
"node.transition.name": "转场"
|
||||
"node.transition.input.tex_in": "起点"
|
||||
"node.transition.input.blend_in": "终点"
|
||||
"node.transition.input.progress_in": "进度"
|
||||
"node.transition.input.type_in": "类型"
|
||||
"node.transitionfx.name": "转场特效"
|
||||
"node.transitionfx.input.tex_in": "起点"
|
||||
"node.transitionfx.input.blend_in": "终点"
|
||||
"node.transitionfx.input.progress_in": "进度"
|
||||
"node.transitionfx.input.color_in": "颜色"
|
||||
"node.transitionfx.input.direction_in": "方向"
|
||||
"node.transitionfx.input.type_in": "类型"
|
||||
"node.cornerpin.name": "角点定位"
|
||||
"node.cornerpin.input.tex_in": "纹理"
|
||||
"node.cornerpin.input.perspective_in": "透视"
|
||||
"node.cornerpin.input.top_left_in": "左上角"
|
||||
"node.cornerpin.input.top_right_in": "右上角"
|
||||
"node.cornerpin.input.bottom_right_in": "右下角"
|
||||
"node.cornerpin.input.bottom_left_in": "左下角"
|
||||
"node.displaytransform.name": "显示变换"
|
||||
"node.displaytransform.input.tex_in": "输入"
|
||||
"node.displaytransform.input.display_in": "显示"
|
||||
"node.displaytransform.input.view_in": "视图"
|
||||
"node.displaytransform.input.dir_in": "方向"
|
||||
"node.ociogradingtransformlinear.name": "OCIO 调色(线性)"
|
||||
"node.ociogradingtransformlinear.input.tex_in": "输入"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_contrast": "对比度"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_offset": "偏移"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_exposure": "曝光"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_saturation": "饱和度"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_pivot": "轴心"
|
||||
"node.ociogradingtransformlinear.input.clamp_black_enable_in": "启用黑场限幅"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampBlack": "黑场限幅"
|
||||
"node.ociogradingtransformlinear.input.clamp_white_enable_in": "启用白场限幅"
|
||||
"node.ociogradingtransformlinear.input.ocio_grading_primary_clampWhite": "白场限幅"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.name": "OCIO 调色(对数)"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.tex_in": "输入"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_brightness": "提升"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_contrast": "增益"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_gamma": "伽马"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_saturation": "饱和度"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_pivot": "轴心"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_black_enable_in": "启用黑场限幅"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampBlack": "黑场限幅"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.clamp_white_enable_in": "启用白场限幅"
|
||||
"node.OCIO_NAMESPACEgradingtransformlog.input.OCIO_NAMESPACE_grading_primary_clampWhite": "白场限幅"
|
||||
"node.whitebalance.name": "白平衡"
|
||||
"node.whitebalance.input.tex_in": "输入"
|
||||
"node.whitebalance.input.temperature_in": "色温 (K)"
|
||||
"node.whitebalance.input.tint_in": "色调"
|
||||
"node.ociolut.name": "OCIO 查找表"
|
||||
"node.ociolut.input.tex_in": "输入"
|
||||
"node.ociolut.input.lut_file_in": "LUT 文件"
|
||||
"node.ociolut.input.lut_dir_in": "方向"
|
||||
"node.threewaycolor.name": "三向调色"
|
||||
"node.threewaycolor.input.tex_in": "输入"
|
||||
"node.threewaycolor.input.shadows_color_in": "阴影"
|
||||
"node.threewaycolor.input.midtones_color_in": "中间调"
|
||||
"node.threewaycolor.input.highlights_color_in": "高光"
|
||||
"node.threewaycolor.input.shadows_amount_in": "阴影量"
|
||||
"node.threewaycolor.input.midtones_amount_in": "中间调量"
|
||||
"node.threewaycolor.input.highlights_amount_in": "高光量"
|
||||
"node.chromakey.name": "色度键"
|
||||
"node.chromakey.input.tex_in": "输入"
|
||||
"node.chromakey.input.color_key": "键控色"
|
||||
"node.chromakey.input.lower_tolerance_in": "下限容差"
|
||||
"node.chromakey.input.upper_tolerance_in": "上限容差"
|
||||
"node.chromakey.input.garbage_in": "垃圾遮罩"
|
||||
"node.chromakey.input.core_in": "核心遮罩"
|
||||
"node.chromakey.input.highlights_in": "高光"
|
||||
"node.chromakey.input.shadows_in": "阴影"
|
||||
"node.chromakey.input.invert_in": "反转遮罩"
|
||||
"node.chromakey.input.mask_only_in": "仅显示遮罩"
|
||||
"node.mask.name": "遮罩"
|
||||
"node.mask.input.base_in": "纹理"
|
||||
"node.mask.input.points_in": "顶点"
|
||||
"node.mask.input.color_in": "颜色"
|
||||
"node.mask.input.invert_in": "反转"
|
||||
"node.mask.input.feather_in": "羽化"
|
||||
"node.dropshadow.name": "投影"
|
||||
"node.dropshadow.input.tex_in": "纹理"
|
||||
"node.dropshadow.input.color_in": "颜色"
|
||||
"node.dropshadow.input.distance_in": "距离"
|
||||
"node.dropshadow.input.angle_in": "角度"
|
||||
"node.dropshadow.input.radius_in": "柔和度"
|
||||
"node.dropshadow.input.opacity_in": "不透明度"
|
||||
"node.dropshadow.input.fast_in": "更快(较低质量)"
|
||||
"node.timeformat.name": "时间格式"
|
||||
"node.timeformat.input.time_in": "时间"
|
||||
"node.timeformat.input.format_in": "格式"
|
||||
"node.timeformat.input.localtime_in": "按本地时间解释"
|
||||
"node.wave.name": "波纹"
|
||||
"node.wave.input.tex_in": "输入"
|
||||
"node.wave.input.frequency_in": "频率"
|
||||
"node.wave.input.intensity_in": "强度"
|
||||
"node.wave.input.evolution_in": "演化"
|
||||
"node.wave.input.vertical_in": "方向"
|
||||
"node.tile.name": "平铺"
|
||||
"node.tile.input.tex_in": "输入"
|
||||
"node.tile.input.scale_in": "缩放"
|
||||
"node.tile.input.position_in": "位置"
|
||||
"node.tile.input.anchor_in": "锚点"
|
||||
"node.tile.input.mirrorx_in": "水平镜像"
|
||||
"node.tile.input.mirrory_in": "垂直镜像"
|
||||
"node.swirl.name": "漩涡"
|
||||
"node.swirl.input.tex_in": "输入"
|
||||
"node.swirl.input.radius_in": "半径"
|
||||
"node.swirl.input.angle_in": "角度"
|
||||
"node.swirl.input.pos_in": "位置"
|
||||
"node.ripple.name": "涟漪"
|
||||
"node.ripple.input.tex_in": "输入"
|
||||
"node.ripple.input.evolution_in": "演化"
|
||||
"node.ripple.input.intensity_in": "强度"
|
||||
"node.ripple.input.frequency_in": "频率"
|
||||
"node.ripple.input.position_in": "位置"
|
||||
"node.ripple.input.stretch_in": "拉伸"
|
||||
"node.colormatrix.name": "颜色矩阵"
|
||||
"node.colormatrix.input.tex_in": "输入"
|
||||
"node.colormatrix.input.m0": "R ← R"
|
||||
"node.colormatrix.input.m1": "R ← G"
|
||||
"node.colormatrix.input.m2": "R ← B"
|
||||
"node.colormatrix.input.m3": "R ← A"
|
||||
"node.colormatrix.input.m4": "G ← R"
|
||||
"node.colormatrix.input.m5": "G ← G"
|
||||
"node.colormatrix.input.m6": "G ← B"
|
||||
"node.colormatrix.input.m7": "G ← A"
|
||||
"node.colormatrix.input.m8": "B ← R"
|
||||
"node.colormatrix.input.m9": "B ← G"
|
||||
"node.colormatrix.input.m10": "B ← B"
|
||||
"node.colormatrix.input.m11": "B ← A"
|
||||
"node.colormatrix.input.m12": "A ← R"
|
||||
"node.colormatrix.input.m13": "A ← G"
|
||||
"node.colormatrix.input.m14": "A ← B"
|
||||
"node.colormatrix.input.m15": "A ← A"
|
||||
"node.dilate.name": "膨胀"
|
||||
"node.dilate.input.tex_in": "输入"
|
||||
"node.dilate.input.radius_in": "半径"
|
||||
"node.edgedetect.name": "边缘检测"
|
||||
"node.edgedetect.input.tex_in": "输入"
|
||||
"node.edgedetect.input.threshold_in": "阈值"
|
||||
"node.erode.name": "腐蚀"
|
||||
"node.erode.input.tex_in": "输入"
|
||||
"node.erode.input.radius_in": "半径"
|
||||
"node.colorcorrect.name": "颜色校正"
|
||||
"node.colorcorrect.input.tex_in": "输入"
|
||||
"node.colorcorrect.input.saturation_in": "饱和度"
|
||||
"node.colorcorrect.input.contrast_in": "对比度"
|
||||
"node.colorcorrect.input.gamma_in": "伽马"
|
||||
"node.colorcorrect.input.gain_in": "增益"
|
||||
"node.colorcorrect.input.offset_in": "偏移"
|
||||
"node.gamma.name": "伽马"
|
||||
"node.gamma.input.tex_in": "输入"
|
||||
"node.gamma.input.gamma_in": "伽马"
|
||||
"node.saturation.name": "饱和度"
|
||||
"node.saturation.input.tex_in": "输入"
|
||||
"node.saturation.input.saturation_in": "饱和度"
|
||||
"node.invert.name": "反转"
|
||||
"node.invert.input.tex_in": "输入"
|
||||
"node.invert.input.invert_r_in": "反转红"
|
||||
"node.invert.input.invert_g_in": "反转绿"
|
||||
"node.invert.input.invert_b_in": "反转蓝"
|
||||
"node.invert.input.invert_a_in": "反转 Alpha"
|
||||
"node.clamp.name": "限幅"
|
||||
"node.clamp.input.tex_in": "输入"
|
||||
"node.clamp.input.min_in": "最小"
|
||||
"node.clamp.input.max_in": "最大"
|
||||
"node.grade.name": "调色"
|
||||
"node.grade.input.tex_in": "输入"
|
||||
"node.grade.input.blackpoint_in": "黑点"
|
||||
"node.grade.input.whitepoint_in": "白点"
|
||||
"node.grade.input.black_in": "黑场输出"
|
||||
"node.grade.input.white_in": "白场输出"
|
||||
"node.grade.input.gamma_in": "伽马"
|
||||
"node.dirblur.name": "方向模糊"
|
||||
"node.dirblur.input.tex_in": "输入"
|
||||
"node.dirblur.input.amount_in": "强度"
|
||||
"node.dirblur.input.angle_in": "角度"
|
||||
"node.sharpen.name": "锐化"
|
||||
"node.sharpen.input.tex_in": "输入"
|
||||
"node.sharpen.input.amount_in": "强度"
|
||||
"node.dissolve.name": "溶解"
|
||||
"node.dissolve.input.tex_in": "输入"
|
||||
"node.dissolve.input.blend_in": "混合层"
|
||||
"node.dissolve.input.mix_in": "混合"
|
||||
"node.keymix.name": "键控混合"
|
||||
"node.keymix.input.tex_in": "输入"
|
||||
"node.keymix.input.blend_in": "混合层"
|
||||
"node.keymix.input.mask_in": "遮罩"
|
||||
"node.premult.name": "预乘"
|
||||
"node.premult.input.tex_in": "输入"
|
||||
"node.premult.input.premult_channel_in": "通道"
|
||||
"node.unpremult.name": "反预乘"
|
||||
"node.unpremult.input.tex_in": "输入"
|
||||
"node.unpremult.input.unpremult_channel_in": "通道"
|
||||
"node.position.name": "位置"
|
||||
"node.position.input.tex_in": "输入"
|
||||
"node.position.input.offset_in": "平移"
|
||||
"node.mirror.name": "镜像"
|
||||
"node.mirror.input.tex_in": "输入"
|
||||
"node.mirror.input.horizontal_in": "水平(翻转)"
|
||||
"node.mirror.input.vertical_in": "垂直(翻转)"
|
||||
"node.checkerboard.name": "棋盘格"
|
||||
"node.checkerboard.input.base_in": "基础"
|
||||
"node.checkerboard.input.size_in": "盒子尺寸"
|
||||
"node.checkerboard.input.color1_in": "颜色 1"
|
||||
"node.checkerboard.input.color2_in": "颜色 2"
|
||||
"node.colorbars.name": "彩条"
|
||||
"node.colorbars.input.base_in": "基础"
|
||||
"node.colorbars.input.standard_in": "标准"
|
||||
"node.ramp.name": "渐变"
|
||||
"node.ramp.input.base_in": "基础"
|
||||
"node.ramp.input.point0_in": "点 0"
|
||||
"node.ramp.input.point1_in": "点 1"
|
||||
"node.ramp.input.color0_in": "颜色 0"
|
||||
"node.ramp.input.color1_in": "颜色 1"
|
||||
"node.multicam.name": "多机位"
|
||||
"node.multicam.input.current_in": "当前"
|
||||
"node.multicam.input.sources_in": "源"
|
||||
"node.multicam.input.sequence_in": "序列"
|
||||
"node.multicam.input.sequence_type_in": "序列类型"
|
||||
"status.ready": "就绪"
|
||||
"status.cache": "缓存:已启用"
|
||||
"status.proxy.on": "代理:开"
|
||||
@@ -479,6 +853,8 @@
|
||||
"node.category.group": "组"
|
||||
"project.context.new": "新建"
|
||||
"project.new_sequence": "新建序列"
|
||||
"project.add_text_footage": "添加文本素材"
|
||||
"timeline.context.add_adjustment_layer": "添加调整图层"
|
||||
"project.context.reveal_in_finder": "在 Finder 中显示"
|
||||
"project.context.replace_footage": "替换素材"
|
||||
"project.context.rename": "重命名"
|
||||
|
||||
@@ -655,6 +655,23 @@ impl<E: AppEngine> OakApp<E> {
|
||||
)
|
||||
.detach();
|
||||
|
||||
// The project explorer's 添加文本素材 button creates a text
|
||||
// generator bin entry in the open project.
|
||||
cx.subscribe(
|
||||
&panels.project,
|
||||
|this,
|
||||
_panel,
|
||||
_event: &crate::panels::project_explorer::NewTextFootageRequested,
|
||||
cx| {
|
||||
this.engine.update(cx, |engine, cx| {
|
||||
if let Err(e) = engine.create_text_footage(cx) {
|
||||
println!("[shell] add text footage: {e}");
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
|
||||
// A footage drop onto a timeline with no open sequence goes through
|
||||
// the probe-vs-manual choice before any sequence is created.
|
||||
cx.subscribe(
|
||||
@@ -5448,6 +5465,46 @@ mod tests {
|
||||
assert_eq!(drops[0].time, gpui::timeline::Frame(50));
|
||||
}
|
||||
|
||||
/// The project explorer's 添加文本素材 button creates a text generator
|
||||
/// bin entry: the click emits `NewTextFootageRequested`, the shell's
|
||||
/// subscription calls `AppEngine::create_text_footage`, and the bin
|
||||
/// lists the new entry (labelled 文本, the shared node label) beside
|
||||
/// the demo rows.
|
||||
#[gpui::test]
|
||||
async fn clicking_add_text_footage_creates_a_text_entry(cx: &mut TestAppContext) {
|
||||
let _guard = crate::i18n::lang_test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let (window, root) = mock_shell(cx);
|
||||
let mut vcx = VisualTestContext::from_window(window.into(), cx);
|
||||
|
||||
let button = vcx
|
||||
.debug_bounds("project-add-text-footage")
|
||||
.expect("the explorer's 添加文本素材 button is rendered");
|
||||
vcx.simulate_click(button.center(), gpui::Modifiers::none());
|
||||
drop(vcx);
|
||||
cx.run_until_parked();
|
||||
|
||||
let created = cx.read(|app| root.read(app).engine.read(app).text_footages().to_vec());
|
||||
assert_eq!(
|
||||
created,
|
||||
vec![100],
|
||||
"the click reached the engine exactly once"
|
||||
);
|
||||
let listed = cx.read(|app| {
|
||||
use gpui_widgets::project_explorer::ProjectDataSource as _;
|
||||
root.read(app)
|
||||
.engine
|
||||
.read(app)
|
||||
.roots()
|
||||
.iter()
|
||||
.any(|e| {
|
||||
e.id == 100 && e.name == crate::oakui::graphops::TEXT_FOOTAGE_LABEL
|
||||
})
|
||||
});
|
||||
assert!(listed, "the created text entry is listed in the bin");
|
||||
}
|
||||
|
||||
/// The project explorer lists root-level imported footage in BOTH views:
|
||||
/// the tree shows the entry as a row, and after switching to the icon
|
||||
/// grid the same entry (plus folder children) appears as icons.
|
||||
|
||||
@@ -141,6 +141,27 @@ pub fn tr(key: &'static str) -> &'static str {
|
||||
key
|
||||
}
|
||||
|
||||
/// Translates a key built at runtime — the built-in effect and parameter
|
||||
/// names, whose keys are `node.<type-id suffix>.name` and
|
||||
/// `node.<type-id suffix>.input.<input id>` (see
|
||||
/// [`oakui::effectchain::addable_effects`](crate::oakui::effectchain::addable_effects)).
|
||||
///
|
||||
/// Same lookup as [`tr`] — the active language, then `en-US` — with the
|
||||
/// node layer's own English name as the last resort: `tr` takes
|
||||
/// `&'static str` keys and returns the key itself on a miss, which for a
|
||||
/// formatted key would put a raw `node.…` identifier in the UI.
|
||||
pub fn tr_or(key: &str, fallback: &str) -> String {
|
||||
let tables = tables().read().unwrap_or_else(|e| e.into_inner());
|
||||
let current = language_code();
|
||||
if let Some(value) = tables.get(¤t).and_then(|t| t.get(key)) {
|
||||
return value.to_string();
|
||||
}
|
||||
if let Some(value) = tables.get(FALLBACK_CODE).and_then(|t| t.get(key)) {
|
||||
return value.to_string();
|
||||
}
|
||||
fallback.to_string()
|
||||
}
|
||||
|
||||
/// The keys the gpui widget crates localize through
|
||||
/// `gpui_widgets::i18n` (viewer transport labels, effect-stack empty state).
|
||||
pub const WIDGET_KEYS: &[&str] = &[
|
||||
@@ -358,6 +379,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every built-in node type the factory registers carries a
|
||||
/// `node.<type-id suffix>.name` entry in the reference pack *and* in
|
||||
/// the translation: the effect library and the inspector build that key
|
||||
/// at runtime, so a node without an entry would show its English name
|
||||
/// in every language — and one whose zh value repeats the English name
|
||||
/// was never translated at all. The matching parameter keys are held by
|
||||
/// `effectchain`'s own test against the live parameter list.
|
||||
#[test]
|
||||
fn every_builtin_node_name_is_translated() {
|
||||
let tables = tables().read().unwrap_or_else(|e| e.into_inner());
|
||||
let en = tables.get("en-US").expect("en-US pack");
|
||||
let zh = tables.get("zh-CN").expect("zh-CN pack");
|
||||
let mut nodes = 0usize;
|
||||
for meta in oak_node::factory::Factory::global().entries() {
|
||||
let suffix = meta.type_id.rsplit('.').next().unwrap_or(meta.type_id);
|
||||
let key = format!("node.{suffix}.name");
|
||||
let en_value = en
|
||||
.get(&key)
|
||||
.unwrap_or_else(|| panic!("{key} missing from the en-US pack"));
|
||||
assert_eq!(
|
||||
*en_value, meta.name,
|
||||
"{key} does not carry the node's English name"
|
||||
);
|
||||
let zh_value = zh
|
||||
.get(&key)
|
||||
.unwrap_or_else(|| panic!("{key} missing from the zh-CN pack"));
|
||||
assert_ne!(zh_value, en_value, "{key} is not translated ({en_value:?})");
|
||||
nodes += 1;
|
||||
}
|
||||
assert!(
|
||||
nodes >= 60,
|
||||
"the factory registers the built-in node types ({nodes})"
|
||||
);
|
||||
}
|
||||
|
||||
/// `tr` falls back from the active language to English and then to
|
||||
/// the key itself.
|
||||
#[test]
|
||||
|
||||
@@ -93,11 +93,96 @@ pub fn is_enabled(g: &Graph, node: NodeId) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The built-in effect group keys, in presentation order.
|
||||
///
|
||||
/// The effect library and the inspector's add menu head the built-in
|
||||
/// effects with these groups, in this order; `general` collects every
|
||||
/// category without a group of its own and sorts last. The keys are
|
||||
/// **language-pack keys**, never display strings: a header's label is
|
||||
/// [`group_label`]'s `effect_library.group.<key>` lookup.
|
||||
pub const GROUP_KEYS: &[&str] = &[
|
||||
"color",
|
||||
"filter",
|
||||
"distort",
|
||||
"keying",
|
||||
"generator",
|
||||
"math",
|
||||
"time",
|
||||
"channel",
|
||||
"transition",
|
||||
"general",
|
||||
];
|
||||
|
||||
/// The group key of a node [`Category`](oak_node::node::Category).
|
||||
///
|
||||
/// The category enum mirrors the C++ `Node::CategoryID`, which is finer
|
||||
/// grained than the create menu: outputs, plain effects, inputs, OpenFX
|
||||
/// plugins, timeline and group nodes have no group of their own and land
|
||||
/// in `general`. `time` / `channel` / `transition` are part of the key
|
||||
/// set (the language packs carry their labels) but no category maps to
|
||||
/// them yet.
|
||||
pub fn category_group_key(category: oak_node::node::Category) -> &'static str {
|
||||
use oak_node::node::Category;
|
||||
match category {
|
||||
Category::Color => "color",
|
||||
Category::Filter => "filter",
|
||||
Category::Distort => "distort",
|
||||
Category::Keying => "keying",
|
||||
Category::Generator => "generator",
|
||||
Category::Math => "math",
|
||||
_ => "general",
|
||||
}
|
||||
}
|
||||
|
||||
/// The display label of an effect-group key: the localized
|
||||
/// `effect_library.group.<key>` string for the built-in keys, and the key
|
||||
/// itself for OpenFX sub-categories (their labels come from the plugin
|
||||
/// descriptor, not from the language packs).
|
||||
pub fn group_label(group_key: &str) -> String {
|
||||
match group_key {
|
||||
"color" => crate::i18n::tr("effect_library.group.color"),
|
||||
"filter" => crate::i18n::tr("effect_library.group.filter"),
|
||||
"distort" => crate::i18n::tr("effect_library.group.distort"),
|
||||
"keying" => crate::i18n::tr("effect_library.group.keying"),
|
||||
"generator" => crate::i18n::tr("effect_library.group.generator"),
|
||||
"math" => crate::i18n::tr("effect_library.group.math"),
|
||||
"time" => crate::i18n::tr("effect_library.group.time"),
|
||||
"channel" => crate::i18n::tr("effect_library.group.channel"),
|
||||
"transition" => crate::i18n::tr("effect_library.group.transition"),
|
||||
"general" => crate::i18n::tr("effect_library.group.general"),
|
||||
// OpenFX sub-categories (and any key a future pack ships without a
|
||||
// label): shown verbatim rather than as a raw pack key.
|
||||
other => other,
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The language-pack key of a built-in node's display name, built at
|
||||
/// runtime: `node.<type-id suffix>.name`, the suffix being the type id's
|
||||
/// last dotted segment (`org.olivevideoeditor.Olive.colorcorrect` →
|
||||
/// `colorcorrect`).
|
||||
fn node_name_key(type_id: &str) -> String {
|
||||
format!("node.{}.name", type_suffix(type_id))
|
||||
}
|
||||
|
||||
/// The language-pack key of a built-in node input's display name:
|
||||
/// `node.<type-id suffix>.input.<input id>`.
|
||||
fn input_name_key(type_id: &str, input_id: &str) -> String {
|
||||
format!("node.{}.input.{input_id}", type_suffix(type_id))
|
||||
}
|
||||
|
||||
/// The last dotted segment of a node type id — the language-pack key's
|
||||
/// `<type-id suffix>`.
|
||||
fn type_suffix(type_id: &str) -> &str {
|
||||
type_id.rsplit('.').next().unwrap_or(type_id)
|
||||
}
|
||||
|
||||
/// The effect types the user can add to a clip's chain — the built-in
|
||||
/// factory entries flagged `video_effect` and not hidden from the create
|
||||
/// menu (no group), plus every runtime-registered OpenFX plugin entry
|
||||
/// (grouped by its sub-category: Filter / Generator / Transition /
|
||||
/// General — the C++ `factorymenu.cpp` OpenFX branch).
|
||||
/// menu (grouped by their first category, see [`category_group_key`]),
|
||||
/// plus every runtime-registered OpenFX plugin entry (grouped by its
|
||||
/// sub-category: Filter / Generator / Transition / General — the C++
|
||||
/// `factorymenu.cpp` OpenFX branch).
|
||||
pub fn addable_effects() -> Vec<super::engine::EffectEntry> {
|
||||
use super::engine::EffectEntry;
|
||||
use oak_node::node::flags as node_flags;
|
||||
@@ -115,10 +200,18 @@ pub fn addable_effects() -> Vec<super::engine::EffectEntry> {
|
||||
} else {
|
||||
meta.name.to_string()
|
||||
};
|
||||
// The library shows the pack's `node.<type-id suffix>.name`; a
|
||||
// type the packs don't know keeps the node's own English name.
|
||||
let name = crate::i18n::tr_or(&node_name_key(meta.type_id), &name);
|
||||
let group = meta
|
||||
.categories
|
||||
.first()
|
||||
.map(|category| category_group_key(*category))
|
||||
.unwrap_or("general");
|
||||
out.push(EffectEntry {
|
||||
type_id: meta.type_id.to_string(),
|
||||
name,
|
||||
group: None,
|
||||
group: Some(group.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -136,18 +229,30 @@ pub fn addable_effects() -> Vec<super::engine::EffectEntry> {
|
||||
group: Some(meta.sub_category),
|
||||
});
|
||||
}
|
||||
// Default presentation order: built-ins first, then the OpenFX
|
||||
// sub-category groups alphabetically; names alphabetical (folded)
|
||||
// within each group.
|
||||
// Default presentation order: built-in groups first, in GROUP_KEYS
|
||||
// order (`general` last), then the OpenFX sub-category groups
|
||||
// alphabetically; names alphabetical (folded) within each group.
|
||||
out.sort_by(|a, b| {
|
||||
let ga = a.group.as_deref().unwrap_or("");
|
||||
let gb = b.group.as_deref().unwrap_or("");
|
||||
ga.cmp(gb)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
match (group_rank(ga), group_rank(gb)) {
|
||||
(Some(ra), Some(rb)) => ra.cmp(&rb),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => ga.cmp(gb),
|
||||
}
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The presentation rank of a built-in group key, or `None` for a key
|
||||
/// outside [`GROUP_KEYS`] (an OpenFX sub-category, which the sort orders
|
||||
/// after every built-in group).
|
||||
fn group_rank(group: &str) -> Option<usize> {
|
||||
GROUP_KEYS.iter().position(|key| *key == group)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OFX parameter data model (stage 6b)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -176,6 +281,13 @@ pub fn effect_params(
|
||||
use oak_node::value::ValueType;
|
||||
|
||||
let entry = g.get(node)?;
|
||||
// The pack keys describe the built-in nodes only: an OpenFX plugin's
|
||||
// labels come from the plugin (and the OFX translation pass), and a
|
||||
// plugin type id happens to end in a built-in type's suffix, the
|
||||
// built-in name would otherwise leak into it.
|
||||
let builtin = oak_node::factory::Factory::global()
|
||||
.find(entry.behavior.type_id())
|
||||
.is_some();
|
||||
let mut out = Vec::new();
|
||||
for input in &entry.core.inputs {
|
||||
// Clip/texture/sample/matrix inputs are graph connections or
|
||||
@@ -202,6 +314,18 @@ pub fn effect_params(
|
||||
} else {
|
||||
input.display_name.clone()
|
||||
};
|
||||
// Built-in nodes label their inputs from the pack instead
|
||||
// (`node.<type-id suffix>.input.<input id>`), so an input the
|
||||
// behavior names in English — or leaves at its raw input id —
|
||||
// follows the active language. The behavior's name is the fallback.
|
||||
let display_name = if builtin {
|
||||
crate::i18n::tr_or(
|
||||
&input_name_key(entry.behavior.type_id(), &input.id),
|
||||
&display_name,
|
||||
)
|
||||
} else {
|
||||
display_name
|
||||
};
|
||||
// Combo options: built-in nodes carry them on the behavior (C++
|
||||
// `set_combo_box_strings`); OFX plugin params already carry the
|
||||
// ("combo_option", _) properties from the translation pass.
|
||||
@@ -215,6 +339,35 @@ pub fn effect_params(
|
||||
));
|
||||
}
|
||||
}
|
||||
// The v3 text node's `font_family_in` is a str-combo whose options
|
||||
// the node layer cannot supply: oak-node deliberately links no font
|
||||
// or shaping crate, so the app's text backend enumerates the system
|
||||
// families and the facade injects them here (the same
|
||||
// `("combo_option", Text)` shape the OFX translation pass uses).
|
||||
if input.id == "font_family_in" && !properties.iter().any(|(k, _)| k == "combo_option") {
|
||||
for name in super::textengine::font_families() {
|
||||
properties.push((
|
||||
"combo_option".to_string(),
|
||||
oak_node::value::NodeValue::Text(name),
|
||||
));
|
||||
}
|
||||
}
|
||||
// The v3 text node's editable text inputs hold more than one line
|
||||
// (the node shapes each `\n`-separated line of the string), and the
|
||||
// inspector renders them as multi-line fields so the user can type
|
||||
// line breaks. The node layer cannot say so — an input carries no
|
||||
// presentation metadata beyond its properties — so the facade marks
|
||||
// the multi-line text ids here, the same way it injects the font
|
||||
// families above.
|
||||
if input.value_type == ValueType::Text
|
||||
&& matches!(input.id.as_str(), "plain_text_in" | "text_in")
|
||||
&& !properties.iter().any(|(k, _)| k == "multiline")
|
||||
{
|
||||
properties.push((
|
||||
"multiline".to_string(),
|
||||
oak_node::value::NodeValue::Boolean(true),
|
||||
));
|
||||
}
|
||||
out.push(super::engine::EffectParam {
|
||||
input_id: input.id.clone(),
|
||||
display_name,
|
||||
@@ -780,21 +933,146 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The effect-library grouping: every addable effect is either an
|
||||
/// ungrouped built-in or an OpenFX entry with one of the four
|
||||
/// sub-categories.
|
||||
/// Every built-in entry carries its category's group key — one of
|
||||
/// [`GROUP_KEYS`], never a display string — and the shipped nodes use
|
||||
/// more than one group (a single "general" for everything would mean
|
||||
/// the category mapping is dead).
|
||||
#[test]
|
||||
fn addable_effects_group_builtins_by_category() {
|
||||
let factory = oak_node::factory::Factory::global();
|
||||
let mut groups: Vec<&'static str> = Vec::new();
|
||||
let mut builtins = 0usize;
|
||||
for entry in addable_effects() {
|
||||
if factory.find(&entry.type_id).is_none() {
|
||||
continue; // an OpenFX plugin entry
|
||||
}
|
||||
builtins += 1;
|
||||
let group = entry
|
||||
.group
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| panic!("built-in {} has no group", entry.type_id));
|
||||
let key = GROUP_KEYS.iter().copied().find(|key| *key == group).unwrap_or_else(|| {
|
||||
panic!("built-in {} has the unknown group {group:?}", entry.type_id)
|
||||
});
|
||||
if !groups.contains(&key) {
|
||||
groups.push(key);
|
||||
}
|
||||
}
|
||||
assert!(builtins > 0, "the factory ships built-in video effects");
|
||||
assert!(
|
||||
groups.len() > 1,
|
||||
"built-ins spread over several groups: {groups:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// OpenFX plugin entries keep their plugin-supplied sub-category
|
||||
/// (Filter / Generator / Transition / General), which never collides
|
||||
/// with a built-in group key.
|
||||
#[test]
|
||||
fn addable_effects_group_openfx_by_sub_category() {
|
||||
let factory = oak_node::factory::Factory::global();
|
||||
for entry in addable_effects() {
|
||||
if let Some(group) = entry.group {
|
||||
if factory.find_dynamic(&entry.type_id).is_none() {
|
||||
continue; // a built-in
|
||||
}
|
||||
let group = entry
|
||||
.group
|
||||
.as_deref()
|
||||
.expect("an OpenFX entry carries a sub-category");
|
||||
assert!(
|
||||
["Filter", "Generator", "Transition", "General"].contains(&group),
|
||||
"unexpected OpenFX sub-category {group:?}"
|
||||
);
|
||||
assert!(
|
||||
!GROUP_KEYS.contains(&group),
|
||||
"OpenFX sub-category {group:?} collides with a built-in group key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sort the library and the inspector render: built-in groups in
|
||||
/// [`GROUP_KEYS`] order, then the OpenFX sub-categories, names folded
|
||||
/// alphabetically inside a group.
|
||||
#[test]
|
||||
fn addable_effects_sort_builtins_first() {
|
||||
let entries = addable_effects();
|
||||
let rank = |entry: &crate::oakui::engine::EffectEntry| {
|
||||
entry
|
||||
.group
|
||||
.as_deref()
|
||||
.and_then(group_rank)
|
||||
.unwrap_or(GROUP_KEYS.len())
|
||||
};
|
||||
for pair in entries.windows(2) {
|
||||
let (a, b) = (&pair[0], &pair[1]);
|
||||
let (ra, rb) = (rank(a), rank(b));
|
||||
if ra != rb {
|
||||
assert!(
|
||||
["Filter", "Generator", "Transition", "General"].contains(&group.as_str()),
|
||||
"unexpected OpenFX sub-category {group:?}"
|
||||
ra < rb,
|
||||
"group order: {a:?} ({ra}) before {b:?} ({rb})"
|
||||
);
|
||||
} else if a.group == b.group {
|
||||
assert!(
|
||||
a.name.to_lowercase() <= b.name.to_lowercase(),
|
||||
"name order inside {:?}: {a:?} before {b:?}",
|
||||
a.group
|
||||
);
|
||||
} else {
|
||||
// Two OpenFX sub-categories (rank only separates built-ins).
|
||||
assert!(
|
||||
a.group.as_deref().unwrap_or("") <= b.group.as_deref().unwrap_or(""),
|
||||
"OpenFX sub-category order: {a:?} before {b:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The category → group-key mapping covers every `Category` variant
|
||||
/// and falls back to `general`.
|
||||
#[test]
|
||||
fn category_group_key_maps_every_category() {
|
||||
use oak_node::node::Category;
|
||||
let all = [
|
||||
Category::Output,
|
||||
Category::Effect,
|
||||
Category::Generator,
|
||||
Category::Input,
|
||||
Category::Math,
|
||||
Category::Color,
|
||||
Category::Distort,
|
||||
Category::Filter,
|
||||
Category::Keying,
|
||||
Category::OpenFx,
|
||||
Category::Timeline,
|
||||
Category::Group,
|
||||
];
|
||||
for category in all {
|
||||
assert!(
|
||||
GROUP_KEYS.contains(&category_group_key(category)),
|
||||
"{category:?} maps outside GROUP_KEYS"
|
||||
);
|
||||
}
|
||||
assert_eq!(category_group_key(Category::Color), "color");
|
||||
assert_eq!(category_group_key(Category::Keying), "keying");
|
||||
assert_eq!(category_group_key(Category::Effect), "general");
|
||||
assert_eq!(category_group_key(Category::OpenFx), "general");
|
||||
}
|
||||
|
||||
/// A group label is the language pack's string for the built-in keys —
|
||||
/// never the raw key — and passes OpenFX sub-categories through.
|
||||
#[test]
|
||||
fn group_label_localizes_keys_and_passes_sub_categories_through() {
|
||||
for key in GROUP_KEYS {
|
||||
let label = group_label(key);
|
||||
assert!(
|
||||
!label.starts_with("effect_library.group."),
|
||||
"language pack entry missing for the {key} group (got {label:?})"
|
||||
);
|
||||
}
|
||||
assert_eq!(group_label("Filter"), "Filter");
|
||||
assert_eq!(group_label("Sapphire"), "Sapphire");
|
||||
}
|
||||
|
||||
/// `set_input_value` writes the standard value undoably and rejects
|
||||
/// unknown input ids.
|
||||
#[test]
|
||||
@@ -1072,4 +1350,180 @@ mod tests {
|
||||
};
|
||||
assert_eq!(ui_section_of(&grouped), Some(("Basic".into(), "Main".into())));
|
||||
}
|
||||
|
||||
/// W6: the v3 text node is a footage entry now (the project panel's
|
||||
/// "add text footage" button and the timeline drop), so the effect
|
||||
/// library / add menus no longer offer it. A text3 node that is in a
|
||||
/// project anyway — a legacy chain, or the one the footage path
|
||||
/// creates — must keep chaining and keep exposing its params.
|
||||
#[test]
|
||||
fn text3_is_not_addable_but_a_chain_node_still_reports_its_params() {
|
||||
let _g = stack_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
|
||||
const TEXT3: &str = "org.olivevideoeditor.Olive.text3";
|
||||
assert!(
|
||||
!addable_effects().iter().any(|e| e.type_id == TEXT3),
|
||||
"text3 left the effect add menus"
|
||||
);
|
||||
|
||||
// `insert` resolves through the factory (which still registers the
|
||||
// type) rather than the addable table: legacy chains and the
|
||||
// footage path keep working.
|
||||
let (project, host) = project_with_clip();
|
||||
let node = insert(&project, host, 0, TEXT3).expect("chain a legacy text3 effect");
|
||||
assert_eq!(chain(&lock(&project).graph, host), vec![node]);
|
||||
|
||||
let params = effect_params(&lock(&project).graph, node).expect("the node exposes params");
|
||||
let plain_text = params
|
||||
.iter()
|
||||
.find(|p| p.input_id == "plain_text_in")
|
||||
.expect("the structured text parameter survives");
|
||||
assert_eq!(plain_text.value_type, oak_node::value::ValueType::Text);
|
||||
// The facade marks the multi-line text ids (the inspector renders a
|
||||
// multi-line field off this property); the node itself carries no
|
||||
// presentation metadata.
|
||||
assert!(
|
||||
plain_text
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "multiline"
|
||||
&& matches!(v, oak_node::value::NodeValue::Boolean(true))),
|
||||
"plain_text_in is marked as multi-line"
|
||||
);
|
||||
assert!(params.iter().any(|p| p.input_id == "font_size_in"));
|
||||
assert!(params.iter().any(|p| p.input_id == "outline_enabled_in"));
|
||||
// The hidden legacy HTML input stays a param *input* in the node,
|
||||
// never in the inspector.
|
||||
assert!(!params.iter().any(|p| p.input_id == "text_in"));
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// The effect library and the inspector name built-in effects and their
|
||||
/// parameters from runtime-built pack keys (`node.<type-id suffix>.name`
|
||||
/// / `node.<type-id suffix>.input.<input id>`): switching the active
|
||||
/// language renames the whole library and parameter list, while a node
|
||||
/// the packs don't know keeps the name it ships with.
|
||||
#[test]
|
||||
fn effect_and_parameter_names_follow_the_language_pack() {
|
||||
let _lang = crate::i18n::lang_test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let _g = stack_lock();
|
||||
oak_undo::global::clear().unwrap();
|
||||
crate::i18n::set_language_code("zh-CN");
|
||||
|
||||
// Every addable built-in shows its pack name, never the factory's
|
||||
// English one.
|
||||
let factory = oak_node::factory::Factory::global();
|
||||
let mut builtins = 0usize;
|
||||
for entry in addable_effects() {
|
||||
let Some(meta) = factory.find(&entry.type_id) else {
|
||||
continue; // an OpenFX plugin entry: the plugin names it
|
||||
};
|
||||
assert_ne!(
|
||||
entry.name, meta.name,
|
||||
"{} is still English in zh-CN",
|
||||
entry.type_id
|
||||
);
|
||||
builtins += 1;
|
||||
}
|
||||
assert!(builtins > 0, "the factory ships addable built-ins");
|
||||
let name_of = |type_id: &str| {
|
||||
addable_effects()
|
||||
.into_iter()
|
||||
.find(|e| e.type_id == type_id)
|
||||
.unwrap_or_else(|| panic!("{type_id} is addable"))
|
||||
.name
|
||||
};
|
||||
assert_eq!(
|
||||
name_of("org.olivevideoeditor.Olive.colorcorrect"),
|
||||
"颜色校正"
|
||||
);
|
||||
assert_eq!(name_of("org.olivevideoeditor.Olive.position"), "位置");
|
||||
|
||||
// The inspector's parameter labels follow the same keys.
|
||||
let (project, host) = project_with_clip();
|
||||
let node = insert(&project, host, 0, "org.olivevideoeditor.Olive.colorcorrect")
|
||||
.expect("chain Color Correct");
|
||||
let params = effect_params(&lock(&project).graph, node).expect("the node exposes params");
|
||||
let label = |input_id: &str| {
|
||||
params
|
||||
.iter()
|
||||
.find(|p| p.input_id == input_id)
|
||||
.unwrap_or_else(|| panic!("{input_id} is a parameter"))
|
||||
.display_name
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(label("saturation_in"), "饱和度");
|
||||
assert_eq!(label("gamma_in"), "伽马");
|
||||
assert_eq!(label("gain_in"), "增益");
|
||||
|
||||
crate::i18n::set_language_code("en-US");
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// The pack coverage behind the lookup above: every name a built-in
|
||||
/// node gives one of its inputs has a `node.<type-id suffix>.input.<id>`
|
||||
/// entry, so no parameter label falls back to English. Inputs the
|
||||
/// behavior does not name (`enabled_in`, `use_args_in`) carry no entry —
|
||||
/// their label is the raw input id, and the enabled input never renders.
|
||||
#[test]
|
||||
fn every_builtin_parameter_name_has_a_pack_entry() {
|
||||
let _lang = crate::i18n::lang_test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
crate::i18n::set_language_code("zh-CN");
|
||||
let mut missing = Vec::new();
|
||||
let mut named = 0usize;
|
||||
for meta in oak_node::factory::Factory::global().entries() {
|
||||
let (core, behavior) = (meta.create)();
|
||||
for input in &core.inputs {
|
||||
let english = behavior.input_name(&input.id);
|
||||
if english == input.id {
|
||||
continue;
|
||||
}
|
||||
if crate::i18n::tr_or(&input_name_key(meta.type_id, &input.id), english) == english {
|
||||
missing.push(format!("node.{}.input.{}", type_suffix(meta.type_id), input.id));
|
||||
}
|
||||
named += 1;
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "no pack entry for {missing:?}");
|
||||
assert!(named >= 250, "the built-in nodes name their inputs ({named})");
|
||||
crate::i18n::set_language_code("en-US");
|
||||
}
|
||||
|
||||
/// The v3 text node's `font_family_in` is a str-combo the node layer
|
||||
/// cannot populate (oak-node links no font crate): `effect_params`
|
||||
/// injects the app's font backend families as its combo options, sorted
|
||||
/// and de-duplicated.
|
||||
#[test]
|
||||
fn text_font_family_combo_is_injected_from_the_font_backend() {
|
||||
let _g = stack_lock();
|
||||
let (project, _clip) = project_with_clip();
|
||||
let node = {
|
||||
let mut g = lock(&project);
|
||||
let (core, behavior) = oak_node::factory::Factory::global()
|
||||
.create_any("org.olivevideoeditor.Olive.text3")
|
||||
.expect("the factory registers the v3 text node");
|
||||
g.graph.add_node(core, behavior)
|
||||
};
|
||||
|
||||
let params = effect_params(&lock(&project).graph, node).expect("the node exposes params");
|
||||
let param = params
|
||||
.iter()
|
||||
.find(|p| p.input_id == "font_family_in")
|
||||
.expect("the font family combo is a parameter");
|
||||
assert_eq!(param.value_type, oak_node::value::ValueType::StrCombo);
|
||||
|
||||
let options = combo_options(param);
|
||||
assert!(!options.is_empty(), "the font backend found no families");
|
||||
assert_eq!(options, crate::oakui::textengine::font_families());
|
||||
let mut sorted = options.clone();
|
||||
sorted.sort();
|
||||
sorted.dedup();
|
||||
assert_eq!(options, sorted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +339,20 @@ pub trait AppEngine:
|
||||
/// it).
|
||||
fn remove_track(&mut self, index: usize, cx: &mut Context<Self>);
|
||||
|
||||
/// Adds an adjustment layer to the display track at `index`, starting
|
||||
/// at `frame` and five seconds long (undoable where the backend
|
||||
/// supports it). Returns a user-facing error message on failure.
|
||||
/// Default: unsupported.
|
||||
fn add_adjustment_layer(
|
||||
&mut self,
|
||||
index: usize,
|
||||
frame: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let _ = (index, frame, cx);
|
||||
Err("add adjustment layer not supported".into())
|
||||
}
|
||||
|
||||
/// Sets the row height of every timeline track (timeline toolbar).
|
||||
fn set_track_height(&mut self, height: Pixels, cx: &mut Context<Self>);
|
||||
|
||||
@@ -1071,6 +1085,15 @@ pub trait AppEngine:
|
||||
Err("no project open".to_string())
|
||||
}
|
||||
|
||||
/// Creates a text generator in the open project's root folder (the
|
||||
/// project explorer's "添加文本素材" action) and returns its entry id.
|
||||
/// The bin entry is NOT a sequence: a double-click selects it like
|
||||
/// footage, and the timeline drop places a generator clip.
|
||||
fn create_text_footage(&mut self, cx: &mut Context<Self>) -> Result<u64, String> {
|
||||
let _ = cx;
|
||||
Err("no project open".to_string())
|
||||
}
|
||||
|
||||
/// Applies new name/format/interlaced parameters to the sequence `id`.
|
||||
fn update_sequence_parameters(
|
||||
&mut self,
|
||||
@@ -1213,6 +1236,18 @@ pub trait AppEngine:
|
||||
let _ = (clips, cx);
|
||||
}
|
||||
|
||||
/// Adds a transition of the configured default length (the
|
||||
/// `DefaultTransitionLength` preference, seconds) at every contiguous
|
||||
/// seam around `clips` — the clip menu's Default Transition item and the
|
||||
/// Ctrl+Shift+D action (the C++ `timeline::add_default_transition`).
|
||||
/// One undoable "Add Transition" entry; returns how many transitions
|
||||
/// were created, or `Err` when no selected seam accepts one.
|
||||
fn add_default_transition(
|
||||
&mut self,
|
||||
clips: Vec<ClipId>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<usize, String>;
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Multi-camera (the C++ MulticamWidget / timeline Multi-Cam menu):
|
||||
// detection state for the panel, angle-frame rendering, the timeline
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -516,6 +516,14 @@ pub struct MockEngine {
|
||||
/// the applied (id, track kind, track index, start frame) — drives
|
||||
/// app-level tests of the explorer→timeline drag).
|
||||
footage_drops: Vec<MockFootageDrop>,
|
||||
/// Text-generator bin entries created via
|
||||
/// [`AppEngine::create_text_footage`] since creation, as entry ids (mock
|
||||
/// state; the mock's [`ProjectDataSource::roots`] lists them beside the
|
||||
/// demo folders and files — drives app-level tests of the "添加文本素材"
|
||||
/// action).
|
||||
text_footages: Vec<u64>,
|
||||
/// Id allocator for the text entries above.
|
||||
next_text_footage_id: u64,
|
||||
/// The fake project library the project manager browses (M13 D4): an
|
||||
/// in-memory row set the library trait methods operate on, so the app
|
||||
/// flow (list / open / create / rename / duplicate / delete / import / /// export) is testable without a database.
|
||||
@@ -811,6 +819,8 @@ impl MockEngine {
|
||||
cpu_frame_cache: Mutex::new(HashMap::new()),
|
||||
imported_footage: Vec::new(),
|
||||
footage_drops: Vec::new(),
|
||||
text_footages: Vec::new(),
|
||||
next_text_footage_id: 100,
|
||||
library: demo_library(),
|
||||
next_library_id: 100,
|
||||
library_opened: Vec::new(),
|
||||
@@ -1347,6 +1357,49 @@ impl AppEngine for MockEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn add_adjustment_layer(
|
||||
&mut self,
|
||||
index: usize,
|
||||
frame: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let Some(kind) = self.tracks.get(index).map(|t| t.kind) else {
|
||||
return Err(format!("no track at index {index}"));
|
||||
};
|
||||
if kind != TrackKind::Video {
|
||||
return Err("adjustment layers are only supported on video tracks".to_string());
|
||||
}
|
||||
// The demo's canned layer: five seconds from the clicked frame,
|
||||
// colored apart from the demo clips so the panel tests can tell it
|
||||
// apart at a glance.
|
||||
let fps = self.frame_rate();
|
||||
let length = (5.0 * fps.num as f64 / fps.den.max(1) as f64)
|
||||
.round()
|
||||
.max(1.0) as i64;
|
||||
let start = Frame(frame.0.max(0));
|
||||
let clip = MockClip {
|
||||
id: ClipId(self.next_mock_clip_id()),
|
||||
range: FrameRange::new(start, Frame(start.0 + length)),
|
||||
media_in: Frame(0),
|
||||
label: "调整图层".into(),
|
||||
color: Hsla {
|
||||
h: 0.55,
|
||||
s: 0.55,
|
||||
l: 0.5,
|
||||
a: 1.0,
|
||||
},
|
||||
};
|
||||
let track = &mut self.tracks[index];
|
||||
let position = track
|
||||
.clips
|
||||
.iter()
|
||||
.position(|c| c.range.start.0 > start.0)
|
||||
.unwrap_or(track.clips.len());
|
||||
track.clips.insert(position, clip);
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_track_height(&mut self, height: Pixels, cx: &mut Context<Self>) {
|
||||
self.set_track_height(height, cx);
|
||||
}
|
||||
@@ -1405,17 +1458,163 @@ impl AppEngine for MockEngine {
|
||||
}
|
||||
|
||||
fn effect_params(&self, effect: EffectId) -> Option<Vec<crate::oakui::engine::EffectParam>> {
|
||||
// A canned text parameter behind a sentinel id, for the params
|
||||
// view's text-field tests.
|
||||
// A canned parameter list behind a sentinel id, mirroring the shape
|
||||
// of the v3 text node's inputs (ids, value types, combo options and
|
||||
// min bounds included) so the params view's tests can cover every
|
||||
// control the text inspector builds. Three entries carry the HIDDEN
|
||||
// flag: a real node's hidden inputs are dropped by the facade's
|
||||
// `effect_params` before they get here, and the view must skip them
|
||||
// on its own too (plugin and mock engines can hand them over).
|
||||
(effect.0 == 900).then(|| {
|
||||
vec![crate::oakui::engine::EffectParam {
|
||||
input_id: "text_in".to_string(),
|
||||
display_name: "Text".to_string(),
|
||||
value_type: oak_node::value::ValueType::Text,
|
||||
value: oak_node::value::NodeValue::Text("<p>engine text</p>".to_string()),
|
||||
flags: 0,
|
||||
properties: Vec::new(),
|
||||
}]
|
||||
use oak_node::input::flags::{ARRAY, HIDDEN};
|
||||
use oak_node::value::{NodeValue, ValueType};
|
||||
|
||||
fn param(
|
||||
input_id: &str,
|
||||
display_name: &str,
|
||||
value_type: ValueType,
|
||||
value: NodeValue,
|
||||
flags: u32,
|
||||
properties: Vec<(&str, NodeValue)>,
|
||||
) -> crate::oakui::engine::EffectParam {
|
||||
crate::oakui::engine::EffectParam {
|
||||
input_id: input_id.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
value_type,
|
||||
value,
|
||||
flags,
|
||||
properties: properties
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
vec![
|
||||
param(
|
||||
"pos_in",
|
||||
"Position",
|
||||
ValueType::Vec2,
|
||||
NodeValue::Vec2([0.0, 0.0]),
|
||||
0,
|
||||
Vec::new(),
|
||||
),
|
||||
param(
|
||||
"size_in",
|
||||
"Size",
|
||||
ValueType::Vec2,
|
||||
NodeValue::Vec2([400.0, 300.0]),
|
||||
0,
|
||||
vec![("min", NodeValue::Vec2([0.0, 0.0]))],
|
||||
),
|
||||
param(
|
||||
"plain_text_in",
|
||||
"Text",
|
||||
ValueType::Text,
|
||||
NodeValue::Text("文本\nsecond line".to_string()),
|
||||
0,
|
||||
vec![("multiline", NodeValue::Boolean(true))],
|
||||
),
|
||||
param(
|
||||
"text_in",
|
||||
"Legacy text",
|
||||
ValueType::Text,
|
||||
NodeValue::Text("<p>engine text</p>".to_string()),
|
||||
HIDDEN,
|
||||
vec![("vieweronly", NodeValue::Boolean(true))],
|
||||
),
|
||||
param(
|
||||
"font_family_in",
|
||||
"Font family",
|
||||
ValueType::StrCombo,
|
||||
NodeValue::StrCombo(String::new()),
|
||||
0,
|
||||
vec![
|
||||
("combo_option", NodeValue::Text("Sans".to_string())),
|
||||
("combo_option", NodeValue::Text("Serif".to_string())),
|
||||
],
|
||||
),
|
||||
param(
|
||||
"font_size_in",
|
||||
"Font size",
|
||||
ValueType::Float,
|
||||
NodeValue::Float(72.0),
|
||||
0,
|
||||
vec![("min", NodeValue::Float(1.0))],
|
||||
),
|
||||
param(
|
||||
"outline_enabled_in",
|
||||
"Outline",
|
||||
ValueType::Boolean,
|
||||
NodeValue::Boolean(false),
|
||||
0,
|
||||
Vec::new(),
|
||||
),
|
||||
param(
|
||||
"outline_color_in",
|
||||
"Outline color",
|
||||
ValueType::Color,
|
||||
NodeValue::Color([0.0, 0.0, 0.0, 1.0]),
|
||||
0,
|
||||
Vec::new(),
|
||||
),
|
||||
param(
|
||||
"outline_width_in",
|
||||
"Outline width",
|
||||
ValueType::Float,
|
||||
NodeValue::Float(2.0),
|
||||
0,
|
||||
vec![("min", NodeValue::Float(0.0))],
|
||||
),
|
||||
param(
|
||||
"glow_enabled_in",
|
||||
"Glow",
|
||||
ValueType::Boolean,
|
||||
NodeValue::Boolean(false),
|
||||
0,
|
||||
Vec::new(),
|
||||
),
|
||||
param(
|
||||
"glow_color_in",
|
||||
"Glow color",
|
||||
ValueType::Color,
|
||||
NodeValue::Color([1.0, 1.0, 0.0, 1.0]),
|
||||
0,
|
||||
Vec::new(),
|
||||
),
|
||||
param(
|
||||
"glow_radius_in",
|
||||
"Glow radius",
|
||||
ValueType::Float,
|
||||
NodeValue::Float(8.0),
|
||||
0,
|
||||
vec![("min", NodeValue::Float(0.0))],
|
||||
),
|
||||
param(
|
||||
"valign_in",
|
||||
"Vertical align",
|
||||
ValueType::Combo,
|
||||
NodeValue::Combo(0),
|
||||
HIDDEN,
|
||||
Vec::new(),
|
||||
),
|
||||
param(
|
||||
"use_args_in",
|
||||
"Use args",
|
||||
ValueType::Boolean,
|
||||
NodeValue::Boolean(true),
|
||||
HIDDEN,
|
||||
Vec::new(),
|
||||
),
|
||||
param(
|
||||
"args_in",
|
||||
"Args",
|
||||
ValueType::Text,
|
||||
NodeValue::Text(String::new()),
|
||||
ARRAY,
|
||||
vec![("arraystart", NodeValue::Int(1))],
|
||||
),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1808,6 +2007,14 @@ impl AppEngine for MockEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_text_footage(&mut self, cx: &mut Context<Self>) -> Result<u64, String> {
|
||||
let id = self.next_text_footage_id;
|
||||
self.next_text_footage_id += 1;
|
||||
self.text_footages.push(id);
|
||||
cx.notify();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn drop_footage(
|
||||
&mut self,
|
||||
id: u64,
|
||||
@@ -2351,6 +2558,16 @@ impl AppEngine for MockEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn add_default_transition(
|
||||
&mut self,
|
||||
_clips: Vec<ClipId>,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Result<usize, String> {
|
||||
// The demo timeline is not backed by a module graph, so there is no
|
||||
// block to hang a transition on.
|
||||
Err("default transition: the demo engine has no project".to_string())
|
||||
}
|
||||
|
||||
fn multicam_switch_to(&mut self, source: i32, split_clip: bool, cx: &mut Context<Self>) {
|
||||
let guard = self.ensure_demo_multicam();
|
||||
let Some(demo) = guard.as_ref() else {
|
||||
@@ -2559,12 +2776,19 @@ impl NodeGraphDataSource for MockEngine {
|
||||
|
||||
impl ProjectDataSource for MockEngine {
|
||||
fn roots(&self) -> Vec<ProjectEntry> {
|
||||
vec![
|
||||
let mut roots = vec![
|
||||
ProjectEntry::new(1, crate::i18n::tr("bin.footage"), true),
|
||||
ProjectEntry::new(2, crate::i18n::tr("bin.music"), true),
|
||||
ProjectEntry::new(3, "第一稿.mp4", false),
|
||||
ProjectEntry::new(4, "aaa.ove", false),
|
||||
]
|
||||
];
|
||||
// Text generators created through the "添加文本素材" action (the
|
||||
// shared node label `文本`, so the demo's browser stays locale-
|
||||
// independent).
|
||||
for &id in &self.text_footages {
|
||||
roots.push(ProjectEntry::new(id, super::graphops::TEXT_FOOTAGE_LABEL, false));
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
fn children(&self, parent_id: u64) -> Vec<ProjectEntry> {
|
||||
@@ -2805,6 +3029,13 @@ impl MockEngine {
|
||||
&self.footage_drops
|
||||
}
|
||||
|
||||
/// The text-generator entries created via
|
||||
/// [`AppEngine::create_text_footage`] so far (mock state; the entry ids
|
||||
/// are also listed by [`ProjectDataSource::roots`]).
|
||||
pub fn text_footages(&self) -> &[u64] {
|
||||
&self.text_footages
|
||||
}
|
||||
|
||||
/// The display name of the project-explorer entry with `id`, if any.
|
||||
fn footage_entry_name(&self, id: u64) -> Option<String> {
|
||||
for entry in self.roots() {
|
||||
@@ -3583,4 +3814,35 @@ mod tests {
|
||||
assert_eq!(engine.read(app).workarea(), Some((Frame(30), Frame(90))));
|
||||
});
|
||||
}
|
||||
|
||||
/// 添加文本素材 on the mock: every call gets a fresh entry id, the ids
|
||||
/// are listed by the bin as leaves labelled with the shared `文本`
|
||||
/// node label (the drop path resolves them by name).
|
||||
#[gpui::test]
|
||||
async fn create_text_footage_lists_a_text_entry(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
let first = engine.update(app, |engine, cx| {
|
||||
engine.create_text_footage(cx).expect("create text footage")
|
||||
});
|
||||
let second = engine.update(app, |engine, cx| {
|
||||
engine.create_text_footage(cx).expect("create text footage")
|
||||
});
|
||||
assert_ne!(first, second, "each action gets a fresh entry id");
|
||||
assert_eq!(engine.read(app).text_footages(), &[first, second]);
|
||||
let listed: Vec<_> = engine
|
||||
.read(app)
|
||||
.roots()
|
||||
.into_iter()
|
||||
.filter(|e| e.id == first || e.id == second)
|
||||
.collect();
|
||||
assert_eq!(listed.len(), 2, "both text entries are listed in the bin");
|
||||
assert!(
|
||||
listed.iter().all(|e| {
|
||||
e.name == crate::oakui::graphops::TEXT_FOOTAGE_LABEL && !e.is_dir
|
||||
}),
|
||||
"the entries carry the shared 文本 label as leaves"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -868,6 +868,12 @@ pub struct RealClip {
|
||||
/// The clip's texture chain contains a multicam node (the timeline
|
||||
/// overlay + the panel's detection read the same truth).
|
||||
multicam: bool,
|
||||
/// Duration in frames of the transition attached to the clip's head
|
||||
/// (the seam shared with the previous clip), if any.
|
||||
in_transition: Option<Frame>,
|
||||
/// Duration in frames of the transition attached to the clip's tail
|
||||
/// (the seam shared with the next clip), if any.
|
||||
out_transition: Option<Frame>,
|
||||
}
|
||||
|
||||
impl ClipData for RealClip {
|
||||
@@ -894,6 +900,14 @@ impl ClipData for RealClip {
|
||||
fn is_multicam(&self) -> bool {
|
||||
self.multicam
|
||||
}
|
||||
|
||||
fn in_transition(&self) -> Option<Frame> {
|
||||
self.in_transition
|
||||
}
|
||||
|
||||
fn out_transition(&self) -> Option<Frame> {
|
||||
self.out_transition
|
||||
}
|
||||
}
|
||||
|
||||
/// A running proxy transcode the engine drains on the tick loop (the
|
||||
@@ -1018,6 +1032,20 @@ impl TrackData for RealTrack {
|
||||
}
|
||||
}
|
||||
|
||||
/// The color of an adjustment layer's timeline band
|
||||
/// ([`RealClip::color`]): a fixed slate that sits outside the per-clip
|
||||
/// palette, so a band that carries no media is recognizable at a glance.
|
||||
const ADJUSTMENT_LAYER_COLOR: Hsla = Hsla {
|
||||
h: 0.72,
|
||||
s: 0.3,
|
||||
l: 0.58,
|
||||
a: 1.0,
|
||||
};
|
||||
|
||||
/// The default label of an adjustment layer (the block itself stores no
|
||||
/// label; the snapshot synthesizes one like it does for unnamed clips).
|
||||
const ADJUSTMENT_LAYER_LABEL: &str = "调整图层";
|
||||
|
||||
/// A deterministic clip color from a stable per-clip index (the module
|
||||
/// graph exposes no clip color).
|
||||
fn clip_color(index: u64) -> Hsla {
|
||||
@@ -1528,6 +1556,10 @@ impl RealEngine {
|
||||
|
||||
/// Builds an engine with no project open.
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
// Text nodes reach their layout/rasterizer through the process-global
|
||||
// hooks of `oak_node::nodes::textbackend` (oak-node links no font or
|
||||
// shaping crate); fill them in before anything can run a node.
|
||||
super::textengine::install();
|
||||
let rate = VideoFormat::hd_1080p25().rate;
|
||||
let (full_res_tx, full_res_rx) = mpsc::channel::<FullResEvent>();
|
||||
let (thumb_tx, thumb_rx) = mpsc::channel::<ThumbEvent>();
|
||||
@@ -3419,6 +3451,9 @@ impl RealEngine {
|
||||
let height = graphops::track_behavior(graph, track)
|
||||
.map(|t| px(oak_node::track::internal_height_to_pixel_height(t.height).max(24) as f32))
|
||||
.unwrap_or(px(64.0));
|
||||
// The transition wedges the track's clips carry (left = head
|
||||
// transition, right = tail transition), keyed by clip block.
|
||||
let widths = graphops::clip_transition_widths(graph, track);
|
||||
let clips = graphops::clip_ids(graph, track)
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -3427,17 +3462,30 @@ impl RealEngine {
|
||||
let to_ts = |r: oak_core::Rational| {
|
||||
tb.map(|tb| graphops::rational_to_ts(r, tb)).unwrap_or(0)
|
||||
};
|
||||
let wedge = |w: Option<oak_core::Rational>| w.map(|r| Frame(to_ts(r)));
|
||||
let (head, tail) = widths.get(&block).copied().unwrap_or((None, None));
|
||||
let entry = graph.get(block)?;
|
||||
let is_adjustment = entry
|
||||
.behavior
|
||||
.as_any()
|
||||
.map(|a| a.is::<oak_node::block::AdjustmentBlockBehavior>())
|
||||
.unwrap_or(false);
|
||||
// The clip's color is locked in at creation time (its
|
||||
// `override_color`); clips without one (older projects)
|
||||
// fall back to the track-relative palette so their color
|
||||
// still varies.
|
||||
let entry = graph.get(block)?;
|
||||
let color = if entry.core.override_color >= 0 {
|
||||
// still varies. Adjustment layers wear their own fixed
|
||||
// color instead of a palette entry: their band carries no
|
||||
// media and must read as an effect layer, not a clip.
|
||||
let color = if is_adjustment {
|
||||
ADJUSTMENT_LAYER_COLOR
|
||||
} else if entry.core.override_color >= 0 {
|
||||
clip_color(entry.core.override_color as u64)
|
||||
} else {
|
||||
clip_color(clip_index as u64)
|
||||
};
|
||||
let label = if entry.core.label.is_empty() {
|
||||
let label = if is_adjustment && entry.core.label.is_empty() {
|
||||
ADJUSTMENT_LAYER_LABEL.to_string()
|
||||
} else if entry.core.label.is_empty() {
|
||||
format!("Clip {}", clip_index + 1)
|
||||
} else {
|
||||
entry.core.label.clone()
|
||||
@@ -3452,6 +3500,8 @@ impl RealEngine {
|
||||
color,
|
||||
block,
|
||||
multicam,
|
||||
in_transition: wedge(head),
|
||||
out_transition: wedge(tail),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -3518,7 +3568,7 @@ impl RealEngine {
|
||||
let block = graphops::id_of(id.0)?;
|
||||
let project = self.project_ref()?;
|
||||
let guard = graphops::lock(project);
|
||||
graphops::clip_behavior(&guard.graph, block)?;
|
||||
graphops::block_core_of(&guard.graph, block)?;
|
||||
// The clip must be on the CURRENT timeline (a stale id of a
|
||||
// removed clip must not resolve).
|
||||
self.tracks
|
||||
@@ -3559,7 +3609,7 @@ impl RealEngine {
|
||||
};
|
||||
let guard = graphops::lock(project);
|
||||
// A clip block node on the current timeline: its stack is the target.
|
||||
if graphops::clip_behavior(&guard.graph, node).is_some()
|
||||
if graphops::block_core_of(&guard.graph, node).is_some()
|
||||
&& self
|
||||
.tracks
|
||||
.iter()
|
||||
@@ -3574,7 +3624,7 @@ impl RealEngine {
|
||||
let Some(block) = graphops::id_of(clip.id.0) else {
|
||||
continue;
|
||||
};
|
||||
if graphops::clip_behavior(&guard.graph, block).is_none() {
|
||||
if graphops::block_core_of(&guard.graph, block).is_none() {
|
||||
continue;
|
||||
}
|
||||
if let Some(effect) = super::effectchain::chain(&guard.graph, block)
|
||||
@@ -4194,6 +4244,34 @@ impl AppEngine for RealEngine {
|
||||
);
|
||||
}
|
||||
|
||||
fn add_adjustment_layer(
|
||||
&mut self,
|
||||
index: usize,
|
||||
frame: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let Some(project) = self.project.clone() else {
|
||||
return Err("add adjustment layer: no project is open".to_string());
|
||||
};
|
||||
let Some(seq) = self.sequence else {
|
||||
return Err("add adjustment layer: no sequence is open".to_string());
|
||||
};
|
||||
let Some(tb) = self.time_base() else {
|
||||
return Err("add adjustment layer: the sequence has no frame rate".to_string());
|
||||
};
|
||||
let Some(track) = self.tracks.get(index).map(|t| t.track) else {
|
||||
return Err(format!("add adjustment layer: no track at index {index}"));
|
||||
};
|
||||
// The default span: five seconds from the clicked frame (the
|
||||
// snapshot's non-snapping approximation of the C++ default).
|
||||
let fps = tb.1 as f64 / tb.0.max(1) as f64;
|
||||
let length = (5.0 * fps).round().max(1.0) as i64;
|
||||
let in_ts = frame.0.max(0);
|
||||
let result = graphops::create_adjustment_layer(&project, seq, track, in_ts, in_ts + length);
|
||||
self.apply_edit(result.clone().map(|_| ()), "add adjustment layer", cx);
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
fn set_track_height(&mut self, height: Pixels, cx: &mut Context<Self>) {
|
||||
let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) else {
|
||||
return;
|
||||
@@ -4676,11 +4754,40 @@ impl AppEngine for RealEngine {
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
// Selection / zoom / transition / track-selected / context-menu:
|
||||
TimelineEvent::TransitionChanged {
|
||||
clip,
|
||||
edge,
|
||||
new_length,
|
||||
} => {
|
||||
let Some(block) = self.clip_block(*clip) else {
|
||||
return;
|
||||
};
|
||||
if self.clip_track_locked(block) {
|
||||
return;
|
||||
}
|
||||
let Some(project) = self.project.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(tb) = self.time_base() else {
|
||||
return;
|
||||
};
|
||||
// The widget works in the sequence's frame unit, the
|
||||
// command layer in seconds.
|
||||
let frame = graphops::ts_to_rational(1, tb);
|
||||
let requested = graphops::ts_to_rational(new_length.0.max(1), tb);
|
||||
let result = graphops::set_transition_length(
|
||||
&project,
|
||||
block,
|
||||
matches!(edge, TrimEdge::Start),
|
||||
frame,
|
||||
requested,
|
||||
);
|
||||
self.apply_edit(result.map(|_| ()), "transition length", cx);
|
||||
}
|
||||
// Selection / zoom / track-selected / context-menu:
|
||||
// not editable (the right-click is answered by the panel's popup).
|
||||
TimelineEvent::SelectionChanged
|
||||
| TimelineEvent::TrackSelected { .. }
|
||||
| TimelineEvent::TransitionChanged { .. }
|
||||
| TimelineEvent::ContextMenuRequested { .. }
|
||||
| TimelineEvent::ZoomChanged(_) => {}
|
||||
TimelineEvent::TrackToggleRequested { track, toggle } => {
|
||||
@@ -5147,14 +5254,24 @@ impl AppEngine for RealEngine {
|
||||
// sequence node (TEXTURE_INPUT, like `clip_connected_sequence`
|
||||
// resolves), so a multicam source sequence drops as a single
|
||||
// multi-cam clip the panel detects.
|
||||
let is_sequence = {
|
||||
//
|
||||
// Text entry drop: a GENERATOR clip — the clip reads the text
|
||||
// node's output through `tex_in` instead of decoding media.
|
||||
let (is_sequence, is_text) = {
|
||||
let guard = graphops::lock(&project);
|
||||
graphops::sequence_behavior(&guard.graph, footage).is_some()
|
||||
(
|
||||
graphops::sequence_behavior(&guard.graph, footage).is_some(),
|
||||
graphops::node_type_id(&guard.graph, footage) == graphops::TEXT_FOOTAGE_TYPE_ID,
|
||||
)
|
||||
};
|
||||
if is_sequence {
|
||||
self.drop_sequence_entry(&project, footage, track_index, time, cx);
|
||||
return;
|
||||
}
|
||||
if is_text {
|
||||
self.drop_text_entry(&project, footage, track_index, time, cx);
|
||||
return;
|
||||
}
|
||||
let (filename, video_streams, total_streams, seconds) = {
|
||||
let guard = graphops::lock(&project);
|
||||
let Some(f) = graphops::footage_behavior(&guard.graph, footage) else {
|
||||
@@ -5738,6 +5855,15 @@ impl AppEngine for RealEngine {
|
||||
Ok(id.identity())
|
||||
}
|
||||
|
||||
fn create_text_footage(&mut self, cx: &mut Context<Self>) -> Result<u64, String> {
|
||||
let Some(project) = self.project.clone() else {
|
||||
return Err(crate::i18n::tr("seqprops.error.no_project").to_string());
|
||||
};
|
||||
let id = graphops::create_text_footage_node(&project)?;
|
||||
self.apply_edit(Ok(()), "add text footage", cx);
|
||||
Ok(id.identity())
|
||||
}
|
||||
|
||||
fn update_sequence_parameters(
|
||||
&mut self,
|
||||
id: u64,
|
||||
@@ -6427,6 +6553,45 @@ impl AppEngine for RealEngine {
|
||||
self.apply_edit(result, "multicam enable/disable", cx);
|
||||
}
|
||||
|
||||
fn add_default_transition(
|
||||
&mut self,
|
||||
clips: Vec<ClipId>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<usize, String> {
|
||||
let Some(project) = self.project.clone() else {
|
||||
return Err("default transition: no project is open".to_string());
|
||||
};
|
||||
let Some(seq) = self.sequence else {
|
||||
return Err("default transition: no sequence is open".to_string());
|
||||
};
|
||||
let Some(tb) = self.time_base() else {
|
||||
return Err("default transition: the sequence has no frame rate".to_string());
|
||||
};
|
||||
// The preference is the WHOLE transition length; a default
|
||||
// transition is symmetric, so each wedge takes half of it (at least
|
||||
// one frame — `ts_to_rational` needs a positive frame count).
|
||||
let seconds: f64 = config_get_string(CONFIG_KEY_DEFAULT_TRANSITION_SEC)
|
||||
.parse()
|
||||
.unwrap_or_else(|_| {
|
||||
DEFAULT_TRANSITION_SEC
|
||||
.parse()
|
||||
.expect("the compiled-in default parses")
|
||||
});
|
||||
let fps = tb.1 as f64 / tb.0.max(1) as f64;
|
||||
let half_frames = (seconds * fps / 2.0).round().max(1.0) as i64;
|
||||
let half = graphops::ts_to_rational(half_frames, tb);
|
||||
let blocks: Vec<NodeId> = clips
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let block = self.clip_block(*id)?;
|
||||
(!self.clip_track_locked(block)).then_some(block)
|
||||
})
|
||||
.collect();
|
||||
let result = graphops::add_default_transition(&project, seq, &blocks, half);
|
||||
self.apply_edit(result.clone().map(|_| ()), "add default transition", cx);
|
||||
result
|
||||
}
|
||||
|
||||
fn multicam_switch_to(&mut self, source: i32, split_clip: bool, cx: &mut Context<Self>) {
|
||||
let Some(project) = self.project.clone() else {
|
||||
return;
|
||||
@@ -6726,6 +6891,64 @@ impl RealEngine {
|
||||
Ok(audio_clip)
|
||||
}
|
||||
|
||||
/// Drops a text-project entry on the timeline as a GENERATOR clip: the
|
||||
/// clip spans five seconds from the drop point and its `tex_in` reads
|
||||
/// the text node, so the generator re-renders at whatever time the
|
||||
/// clip spans (no media to decode and no footage length to clamp to).
|
||||
/// The target track policy is the sequence drop's: the pointed display
|
||||
/// row when it is a video track, else the first video track. Undoable
|
||||
/// as one "Add Clip".
|
||||
fn drop_text_entry(
|
||||
&mut self,
|
||||
project: &ProjectRef,
|
||||
text: NodeId,
|
||||
track_index: usize,
|
||||
time: Frame,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(host_seq) = self.sequence else {
|
||||
println!("[real engine] drop text: no sequence open");
|
||||
return;
|
||||
};
|
||||
let video_target = if self
|
||||
.tracks
|
||||
.get(track_index)
|
||||
.is_some_and(|t| t.kind == TrackKind::Video)
|
||||
{
|
||||
track_index
|
||||
} else if let Some(index) = self.tracks.iter().position(|t| t.kind == TrackKind::Video)
|
||||
{
|
||||
index
|
||||
} else {
|
||||
println!("[real engine] drop text: no video track");
|
||||
return;
|
||||
};
|
||||
let video_index = self.tracks[video_target].track_index;
|
||||
// Five seconds of the host sequence's frames (the drop's default
|
||||
// extent — the text clip has no media length to take one from).
|
||||
let (in_ts, length_ts) = {
|
||||
let guard = graphops::lock(project);
|
||||
let Ok(tb) = graphops::sequence_time_base(&guard.graph, host_seq)
|
||||
.ok_or_else(|| "host sequence has no frame rate".to_string())
|
||||
else {
|
||||
println!("[real engine] drop text: host has no frame rate");
|
||||
return;
|
||||
};
|
||||
let fps_f = tb.1.max(1) as f64 / tb.0.max(1) as f64;
|
||||
((time.0.max(0)), ((5.0 * fps_f).round() as i64).max(1))
|
||||
};
|
||||
let placed = graphops::place_text_clip(
|
||||
project,
|
||||
host_seq,
|
||||
text,
|
||||
video_index,
|
||||
in_ts,
|
||||
in_ts + length_ts,
|
||||
);
|
||||
let result = placed.map(|_| ());
|
||||
self.apply_edit(result, "drop text", cx);
|
||||
}
|
||||
|
||||
/// Drops a sequence-project entry on the timeline as a NESTED-SEQUENCE
|
||||
/// clip: ONE video clip on the video track (not the sequence's tracks —
|
||||
/// a frame, not a folder, of tracks), fed from the sequence node. This
|
||||
@@ -10258,6 +10481,315 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 添加文本素材 through the engine facade: `create_text_footage`
|
||||
/// creates a `text3` generator labelled "文本" mounted under the root
|
||||
/// (the explorer lists it; it is NOT a sequence), and dropping it lands
|
||||
/// a five-second generator clip on the host sequence's first video
|
||||
/// track, wired through `tex_in` and undoable as one row.
|
||||
#[gpui::test]
|
||||
async fn engine_creates_text_footage_and_drops_it_on_the_timeline(
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) {
|
||||
let _media = media_lock();
|
||||
let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx)));
|
||||
cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx)));
|
||||
|
||||
let text = cx.update(|app| {
|
||||
engine
|
||||
.update(app, |engine, cx| engine.create_text_footage(cx))
|
||||
.expect("create text footage")
|
||||
});
|
||||
let project = cx.read(|app| engine.read(app).project.clone().expect("project"));
|
||||
let node = graphops::id_of(text).expect("text node");
|
||||
{
|
||||
let guard = graphops::lock(&project);
|
||||
assert_eq!(
|
||||
graphops::node_type_id(&guard.graph, node),
|
||||
graphops::TEXT_FOOTAGE_TYPE_ID
|
||||
);
|
||||
assert_eq!(
|
||||
graphops::node_label(&guard.graph, node),
|
||||
graphops::TEXT_FOOTAGE_LABEL
|
||||
);
|
||||
let children = guard
|
||||
.graph
|
||||
.get(guard.root)
|
||||
.and_then(|e| e.behavior.as_any())
|
||||
.and_then(|a| a.downcast_ref::<oak_node::folder::FolderBehavior>())
|
||||
.map(|f| f.children.clone())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
children.contains(&node),
|
||||
"the text entry mounts under the root folder"
|
||||
);
|
||||
}
|
||||
let is_seq = cx.read(|app| engine.read(app).entry_is_sequence(text));
|
||||
assert!(!is_seq, "a text entry is not a sequence");
|
||||
|
||||
// Open a host sequence; the drop targets its first video track.
|
||||
let seq = cx.update(|app| {
|
||||
engine
|
||||
.update(app, |engine, cx| {
|
||||
engine
|
||||
.create_sequence_with_params(
|
||||
"Text Drop".to_string(),
|
||||
VideoFormat {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
rate: FrameRate::new(30000, 1001),
|
||||
},
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.expect("create sequence")
|
||||
})
|
||||
});
|
||||
cx.update(|app| {
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.drop_footage(text, TrackKind::Video, 0, Frame(25), cx)
|
||||
})
|
||||
});
|
||||
|
||||
let seq_node = graphops::id_of(seq).expect("sequence node");
|
||||
// The drop row is a display row: the engine's track view runs
|
||||
// top-to-bottom, so row 0 is the topmost video track of the
|
||||
// sequence's list (V2 with the default layout).
|
||||
let target_row = cx.read(|app| {
|
||||
let row = &engine.read(app).tracks[0];
|
||||
assert_eq!(row.kind, TrackKind::Video, "the drop row is a video track");
|
||||
row.track_index
|
||||
});
|
||||
let (tb, tracks) = {
|
||||
let guard = graphops::lock(&project);
|
||||
(
|
||||
graphops::sequence_time_base(&guard.graph, seq_node).expect("time base"),
|
||||
graphops::track_ids(&guard.graph, seq_node, TrackType::Video),
|
||||
)
|
||||
};
|
||||
let track = tracks[target_row];
|
||||
{
|
||||
let guard = graphops::lock(&project);
|
||||
let clips = graphops::clip_ids(&guard.graph, track);
|
||||
assert_eq!(clips.len(), 1, "the drop lands exactly one clip: {clips:?}");
|
||||
let clip = clips[0];
|
||||
assert_eq!(
|
||||
graphops::clip_track(&guard.graph, clip),
|
||||
Some(track),
|
||||
"the clip lands on the drop row's video track"
|
||||
);
|
||||
for (index, other) in tracks.iter().enumerate() {
|
||||
if index == target_row {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
graphops::clip_ids(&guard.graph, *other).is_empty(),
|
||||
"the other video tracks stay untouched"
|
||||
);
|
||||
}
|
||||
let (in_r, out_r, _) = graphops::clip_range(&guard.graph, clip).expect("clip range");
|
||||
let fps = tb.1 as f64 / tb.0 as f64;
|
||||
let length = (5.0 * fps).round().max(1.0) as i64;
|
||||
assert_eq!(in_r, graphops::ts_to_rational(25, tb));
|
||||
assert_eq!(
|
||||
out_r,
|
||||
graphops::ts_to_rational(25 + length, tb),
|
||||
"the clip spans five seconds from the drop point"
|
||||
);
|
||||
assert_eq!(
|
||||
guard
|
||||
.graph
|
||||
.connected_output(clip, oak_node::block::clip_input::TEXTURE_INPUT, -1),
|
||||
Some(node),
|
||||
"the clip reads the generator through tex_in"
|
||||
);
|
||||
}
|
||||
|
||||
// One undo removes the clip (and its edge) again.
|
||||
oak_undo::global::undo().unwrap();
|
||||
{
|
||||
let guard = graphops::lock(&project);
|
||||
assert!(
|
||||
graphops::clip_ids(&guard.graph, track).is_empty(),
|
||||
"the undo removes the dropped clip"
|
||||
);
|
||||
assert!(
|
||||
graphops::track_behavior(&guard.graph, track)
|
||||
.map(|t| t.blocks.is_empty())
|
||||
.unwrap_or(false),
|
||||
"the undo also drops the gap the placement inserted"
|
||||
);
|
||||
}
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// 调整图层 through the engine facade: `add_adjustment_layer` places a
|
||||
/// five-second empty block at the clicked frame on a video track (an
|
||||
/// `AdjustmentBlockBehavior` with no footage and no texture wiring),
|
||||
/// refuses audio tracks, resolves through the timeline selection, and is
|
||||
/// undone by one undo step.
|
||||
#[gpui::test]
|
||||
async fn engine_adds_and_undoes_adjustment_layer(cx: &mut gpui::TestAppContext) {
|
||||
let _media = media_lock();
|
||||
let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx)));
|
||||
cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx)));
|
||||
|
||||
let seq = cx.update(|app| {
|
||||
engine.update(app, |engine, cx| {
|
||||
engine
|
||||
.create_sequence_with_params(
|
||||
"Adjustment Layer".to_string(),
|
||||
VideoFormat {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
rate: FrameRate::new(25, 1),
|
||||
},
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.expect("create sequence")
|
||||
})
|
||||
});
|
||||
let seq_node = graphops::id_of(seq).expect("sequence node");
|
||||
let project = cx.read(|app| engine.read(app).project.clone().expect("project"));
|
||||
let tb = {
|
||||
let guard = graphops::lock(&project);
|
||||
graphops::sequence_time_base(&guard.graph, seq_node).expect("time base")
|
||||
};
|
||||
let fps = tb.1 as f64 / tb.0.max(1) as f64;
|
||||
let length = (5.0 * fps).round().max(1.0) as i64;
|
||||
|
||||
// An audio row is not a legal host.
|
||||
let audio_row = cx.read(|app| {
|
||||
engine
|
||||
.read(app)
|
||||
.tracks
|
||||
.iter()
|
||||
.position(|row| row.kind == TrackKind::Audio)
|
||||
.expect("the default layout has audio rows")
|
||||
});
|
||||
let rejected = cx.update(|app| {
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.add_adjustment_layer(audio_row, Frame(0), cx)
|
||||
})
|
||||
});
|
||||
let error = rejected.expect_err("audio tracks reject adjustment layers");
|
||||
assert!(
|
||||
error.contains("video track"),
|
||||
"the rejection names the constraint: {error}"
|
||||
);
|
||||
|
||||
// Row 0 is the topmost video row; frame 12 anchors the new layer.
|
||||
let target_row = cx.read(|app| {
|
||||
let row = &engine.read(app).tracks[0];
|
||||
assert_eq!(row.kind, TrackKind::Video, "row 0 is a video track");
|
||||
row.track_index
|
||||
});
|
||||
cx.update(|app| {
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.add_adjustment_layer(0, Frame(12), cx)
|
||||
})
|
||||
})
|
||||
.expect("add adjustment layer");
|
||||
|
||||
// The snapshot carries the new layer: the fixed slate (outside the
|
||||
// per-clip palette), the synthesized label, and the five-second span
|
||||
// from the clicked frame.
|
||||
let (track, clip) = cx.read(|app| {
|
||||
let engine = engine.read(app);
|
||||
let row = &engine.tracks[0];
|
||||
assert_eq!(row.clips.len(), 1, "the add lands exactly one block");
|
||||
(row.track, row.clips[0].clone())
|
||||
});
|
||||
assert_eq!(clip.range, FrameRange::new(Frame(12), Frame(12 + length)));
|
||||
assert_eq!(clip.media_in, Frame(0));
|
||||
assert_eq!(clip.label, SharedString::from("调整图层"));
|
||||
assert_eq!(clip.color, ADJUSTMENT_LAYER_COLOR);
|
||||
assert!(
|
||||
(0..16).all(|index| clip.color != clip_color(index)),
|
||||
"the adjustment slate sits outside the per-clip palette"
|
||||
);
|
||||
let block = clip.block;
|
||||
|
||||
// The graph holds an adjustment block on the row's track, spanning
|
||||
// the same range, with no texture source.
|
||||
{
|
||||
let guard = graphops::lock(&project);
|
||||
let videos = graphops::track_ids(&guard.graph, seq_node, TrackType::Video);
|
||||
assert_eq!(
|
||||
videos[target_row], track,
|
||||
"the display row maps to the graph track"
|
||||
);
|
||||
assert_eq!(graphops::clip_track(&guard.graph, block), Some(track));
|
||||
assert_eq!(
|
||||
graphops::clip_ids(&guard.graph, track),
|
||||
vec![block],
|
||||
"the placed block is the track's only clip"
|
||||
);
|
||||
for (index, other) in videos.iter().enumerate() {
|
||||
if index == target_row {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
graphops::clip_ids(&guard.graph, *other).is_empty(),
|
||||
"the other video tracks stay untouched"
|
||||
);
|
||||
}
|
||||
let entry = guard.graph.get(block).expect("block node");
|
||||
assert!(
|
||||
entry
|
||||
.behavior
|
||||
.as_any()
|
||||
.map(|a| a.is::<oak_node::block::AdjustmentBlockBehavior>())
|
||||
.unwrap_or(false),
|
||||
"the placed block carries the adjustment behavior"
|
||||
);
|
||||
let (in_r, out_r, media_in) =
|
||||
graphops::clip_range(&guard.graph, block).expect("clip range");
|
||||
assert_eq!(in_r, graphops::ts_to_rational(12, tb));
|
||||
assert_eq!(
|
||||
out_r,
|
||||
graphops::ts_to_rational(12 + length, tb),
|
||||
"the layer spans five seconds from the clicked frame"
|
||||
);
|
||||
assert_eq!(media_in, oak_core::Rational::new(0, 1));
|
||||
assert!(
|
||||
!guard.graph.is_input_connected(
|
||||
block,
|
||||
oak_node::block::adjustment_input::TEXTURE_INPUT,
|
||||
-1
|
||||
),
|
||||
"the adjustment layer carries no texture source"
|
||||
);
|
||||
}
|
||||
|
||||
// The timeline selection resolves the snapshot id to the block (the
|
||||
// inspector targets adjustment layers like any clip).
|
||||
cx.update(|app| {
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.set_selected_clips(vec![ClipId(block.identity())], cx)
|
||||
})
|
||||
});
|
||||
let selected = cx.read(|app| engine.read(app).selected_clip_node());
|
||||
assert_eq!(selected, Some(block), "the selected layer resolves to its block");
|
||||
|
||||
// One undo removes the placement (and the leading gap) again.
|
||||
oak_undo::global::undo().unwrap();
|
||||
{
|
||||
let guard = graphops::lock(&project);
|
||||
assert!(
|
||||
graphops::clip_ids(&guard.graph, track).is_empty(),
|
||||
"the undo removes the placed adjustment layer"
|
||||
);
|
||||
assert!(
|
||||
graphops::track_behavior(&guard.graph, track)
|
||||
.map(|t| t.blocks.is_empty())
|
||||
.unwrap_or(false),
|
||||
"the undo also drops the gap the placement inserted"
|
||||
);
|
||||
}
|
||||
oak_undo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// `update_sequence_parameters` rewrites the name, the format and the
|
||||
/// interlace flag in one call; `sequence_parameters` reads the new
|
||||
/// state back (the properties dialog's commit path).
|
||||
|
||||
@@ -35,7 +35,9 @@ use oak_node::id::NodeId;
|
||||
use oak_node::track::TrackType;
|
||||
use oak_render::manager::RenderManager;
|
||||
use oak_render::procpool::ShmFrameRef;
|
||||
use oak_render::ticket::{AudioTicketParams, MontageClip, TicketPayload, VideoTicketParams};
|
||||
use oak_render::ticket::{
|
||||
AdjustmentSpan, AudioTicketParams, MontageClip, TicketPayload, VideoTicketParams,
|
||||
};
|
||||
|
||||
use super::engine::{ExportEvent, ExportSession};
|
||||
use super::frames::{bgra_bytes_to_render_image, f32_rgba_to_bgra_image};
|
||||
@@ -240,17 +242,29 @@ fn clip_effects(g: &oak_node::graph::Graph, block_id: NodeId) -> Vec<oak_render:
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The video montage at sequence time `time`: every clip covering `time`
|
||||
/// The video montage at sequence time `time`, together with the
|
||||
/// adjustment layers over that time (M14 W3): every clip covering `time`
|
||||
/// on video tracks, ordered bottom-to-top (the highest-numbered track —
|
||||
/// the list's last — is topmost, so it is composited last; NLE stacking
|
||||
/// matches the timeline UI, which shows the highest-numbered track on
|
||||
/// top). Hidden tracks (the muted flag doubles as the video visibility
|
||||
/// toggle, Olive parity) contribute nothing.
|
||||
pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec<MontageClip> {
|
||||
///
|
||||
/// The adjustment spans come out in the same bottom-up walk order (their
|
||||
/// `track_index` boundaries are therefore non-decreasing) and, exactly
|
||||
/// like the graph path, an enabled adjustment block covering `time`
|
||||
/// takes over its track: the span is emitted and the track's own clips
|
||||
/// are skipped, whether or not the adjustment's chain has any effects.
|
||||
pub fn video_montage_with_adjustments(
|
||||
p: &ProjectRef,
|
||||
seq: NodeId,
|
||||
time: Rational,
|
||||
) -> (Vec<MontageClip>, Vec<AdjustmentSpan>) {
|
||||
let g = lock(p);
|
||||
let mut clips = Vec::new();
|
||||
let mut adjustments = Vec::new();
|
||||
let Some(s) = sequence_behavior(&g.graph, seq) else {
|
||||
return clips;
|
||||
return (clips, adjustments);
|
||||
};
|
||||
for &list_id in &s.track_lists {
|
||||
let Some(list) = track_list_behavior(&g.graph, list_id) else {
|
||||
@@ -266,6 +280,13 @@ pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec<Montage
|
||||
if track.muted {
|
||||
continue;
|
||||
}
|
||||
// Everything composited so far sits under this track, so the
|
||||
// span's boundary is the clip count before the track's own
|
||||
// clips join the list.
|
||||
if let Some(span) = covering_adjustment(&g.graph, &track.blocks, time, clips.len()) {
|
||||
adjustments.push(span);
|
||||
continue;
|
||||
}
|
||||
for &block_id in &track.blocks {
|
||||
let Some(clip) = clip_behavior(&g.graph, block_id) else {
|
||||
continue;
|
||||
@@ -305,7 +326,51 @@ pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec<Montage
|
||||
}
|
||||
}
|
||||
}
|
||||
clips
|
||||
(clips, adjustments)
|
||||
}
|
||||
|
||||
/// The video montage at sequence time `time`. The adjustment layers are
|
||||
/// dropped; callers that render (as opposed to previewing the clip list)
|
||||
/// want [`video_montage_with_adjustments`].
|
||||
pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec<MontageClip> {
|
||||
video_montage_with_adjustments(p, seq, time).0
|
||||
}
|
||||
|
||||
/// The adjustment span `blocks` contributes at `time`, if any: the first
|
||||
/// enabled adjustment block covering `time` — the graph path's track
|
||||
/// takeover rule (`render_graph_frame`), so both paths agree on which
|
||||
/// blocks are layers and which track each takes over. `below` is the
|
||||
/// number of clips already composited from the tracks underneath (the
|
||||
/// span's montage-list boundary).
|
||||
fn covering_adjustment(
|
||||
g: &oak_node::graph::Graph,
|
||||
blocks: &[NodeId],
|
||||
time: Rational,
|
||||
below: usize,
|
||||
) -> Option<AdjustmentSpan> {
|
||||
for &block_id in blocks {
|
||||
let Some(block) = g.get(block_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(adjustment) = block
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<oak_node::block::AdjustmentBlockBehavior>())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let in_ = adjustment.core.in_();
|
||||
let out = adjustment.core.out();
|
||||
if adjustment.core.enabled && time >= in_ && time < out {
|
||||
return Some(AdjustmentSpan {
|
||||
in_time: in_,
|
||||
out_time: out,
|
||||
track_index: below,
|
||||
effects: clip_effects(g, block_id),
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The video montage at sequence time `time` containing ONLY the clip on
|
||||
@@ -403,6 +468,7 @@ pub fn multicam_angle_frame_params(
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: single_track_video_montage(p, seq, track, time),
|
||||
adjustments: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -810,9 +876,10 @@ pub fn sequence_frame_params(
|
||||
// 720p proxy up to a 1080p target.
|
||||
let (width, height) = clamp_render_size(width, height, &proxy_limits_at(p, seq, time));
|
||||
// Bind the uuid before the literal: an inline `lock(p)` temporary in the
|
||||
// block-tail struct expression would still be alive when `video_montage`
|
||||
// re-locks the project, deadlocking the same thread.
|
||||
// block-tail struct expression would still be alive when the montage
|
||||
// builder re-locks the project, deadlocking the same thread.
|
||||
let project = lock(p).uuid.clone();
|
||||
let (montage, adjustments) = video_montage_with_adjustments(p, seq, time);
|
||||
Ok(VideoTicketParams {
|
||||
viewer: seq.identity(),
|
||||
project,
|
||||
@@ -824,7 +891,8 @@ pub fn sequence_frame_params(
|
||||
cache_id: None,
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: video_montage(p, seq, time),
|
||||
montage,
|
||||
adjustments,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -891,6 +959,7 @@ pub fn footage_frame_params(
|
||||
cache_timebase: None,
|
||||
footage: Some((filename, stream_index)),
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1412,6 +1481,7 @@ mod tests {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage,
|
||||
adjustments: Vec::new(),
|
||||
};
|
||||
let mut dst = vec![0u8; 64 * 64 * 16];
|
||||
oak_render::eval::render_montage_frame_into(time, ¶ms, (64, 64), &mut dst, 64 * 16)
|
||||
@@ -1488,6 +1558,7 @@ mod tests {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage,
|
||||
adjustments: Vec::new(),
|
||||
};
|
||||
let mut dst = vec![0u8; 64 * 64 * 16];
|
||||
oak_render::eval::render_montage_frame_into(time, ¶ms, (64, 64), &mut dst, 64 * 16)
|
||||
@@ -1555,6 +1626,349 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&media);
|
||||
}
|
||||
|
||||
/// An adjustment-layer block spanning `[in_ts, out_ts)` on `track`
|
||||
/// carrying one Opacity effect at `opacity` — the timeline's
|
||||
/// adjustment-layer drop plus the effect library's append, wired the
|
||||
/// way the inspector does it. Returns `(block, effect)`.
|
||||
fn adjustment_layer_with_opacity(
|
||||
p: &ProjectRef,
|
||||
seq: NodeId,
|
||||
track: NodeId,
|
||||
in_ts: i64,
|
||||
out_ts: i64,
|
||||
opacity: f64,
|
||||
) -> (NodeId, NodeId) {
|
||||
let (tb, in_r) = {
|
||||
let g = lock(p);
|
||||
let tb = graphops::sequence_time_base(&g.graph, seq).expect("the sequence has a timebase");
|
||||
(tb, graphops::ts_to_rational(in_ts, tb))
|
||||
};
|
||||
let block = {
|
||||
let mut g = lock(p);
|
||||
let (core, behavior) = oak_node::block::adjustment_create();
|
||||
let id = g.graph.add_node(core, behavior);
|
||||
let entry = g.graph.get_mut(id).expect("the new block");
|
||||
let adjustment = entry
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.and_then(|a| a.downcast_mut::<oak_node::block::AdjustmentBlockBehavior>())
|
||||
.expect("an adjustment behavior");
|
||||
adjustment.core.range = TimeRange::new(in_r, graphops::ts_to_rational(out_ts, tb));
|
||||
id
|
||||
};
|
||||
let (list, index) = {
|
||||
let g = lock(p);
|
||||
let list = graphops::track_behavior(&g.graph, track)
|
||||
.and_then(|t| t.track_list)
|
||||
.expect("the track has a list");
|
||||
let index = graphops::track_list_behavior(&g.graph, list)
|
||||
.and_then(|l| l.tracks.iter().position(|&t| t == track))
|
||||
.expect("the track is in its list") as i32;
|
||||
(list, index)
|
||||
};
|
||||
graphops::push_command(
|
||||
oak_timeline::undopointer::TrackPlaceBlockCommand::new(
|
||||
graphops::node_ref(p, list),
|
||||
index,
|
||||
graphops::node_ref(p, block),
|
||||
in_r,
|
||||
)
|
||||
.to_command(),
|
||||
"Add Adjustment Layer",
|
||||
)
|
||||
.expect("place the adjustment layer");
|
||||
let fx = crate::oakui::effectchain::insert(
|
||||
p,
|
||||
block,
|
||||
usize::MAX,
|
||||
"org.olivevideoeditor.Olive.opacity",
|
||||
)
|
||||
.expect("append the opacity effect");
|
||||
crate::oakui::effectchain::set_input_value(
|
||||
p,
|
||||
fx,
|
||||
"opacity_in",
|
||||
oak_node::value::NodeValue::Float(opacity),
|
||||
)
|
||||
.expect("set the opacity value");
|
||||
(block, fx)
|
||||
}
|
||||
|
||||
/// M14 W3 montage path: an adjustment layer ABOVE the clip stack
|
||||
/// grades the frame accumulated underneath it — a 50% Opacity on the
|
||||
/// layer halves every channel (alpha included) of the accumulated
|
||||
/// composite, and only inside the layer's range.
|
||||
#[test]
|
||||
fn montage_adjustment_span_halves_the_accumulated_frame() {
|
||||
let _media = media_lock();
|
||||
// Pin the legacy sRGB pass-through (see the effect-stack test) so the
|
||||
// pixel assertions below do not depend on the ACEScg default.
|
||||
oak_core::color::set_pipeline_color_settings(
|
||||
oak_core::colormath::WorkingColorSpace::SrgbLegacy,
|
||||
oak_core::colormath::OutputColorSpec::default(),
|
||||
);
|
||||
oak_undo::global::clear().unwrap();
|
||||
let red = std::env::temp_dir().join(format!("oakapp_adj_red_{}.mp4", std::process::id()));
|
||||
let blue = std::env::temp_dir().join(format!("oakapp_adj_blue_{}.mp4", std::process::id()));
|
||||
oak_codec::testmedia::write_test_clip_solid(&red, 64, 64, 10, 10, [0.9, 0.1, 0.1, 1.0])
|
||||
.expect("generate the red media");
|
||||
oak_codec::testmedia::write_test_clip_solid(&blue, 64, 64, 10, 10, [0.1, 0.1, 0.9, 1.0])
|
||||
.expect("generate the blue media");
|
||||
|
||||
let project = graphops::create_project();
|
||||
let seq = graphops::create_sequence(&project, "Adjustment Montage");
|
||||
let red_footage = graphops::import_footage(&project, &red).expect("import the red media");
|
||||
let blue_footage = graphops::import_footage(&project, &blue).expect("import the blue media");
|
||||
graphops::place_footage_clip(&project, seq, red_footage, TrackType::Video, 0, 0, 10, 0)
|
||||
.expect("place the V1 (red) clip");
|
||||
graphops::place_footage_clip(&project, seq, blue_footage, TrackType::Video, 1, 0, 10, 0)
|
||||
.expect("place the V2 (blue) clip");
|
||||
// A third video track (V3, above both clips) carries the layer.
|
||||
graphops::add_track(&project, seq, TrackType::Video).expect("add the adjustment track");
|
||||
let adj_track = {
|
||||
let g = lock(&project);
|
||||
graphops::track_ids(&g.graph, seq, TrackType::Video)[2]
|
||||
};
|
||||
let (adj, _fx) = adjustment_layer_with_opacity(&project, seq, adj_track, 3, 7, 0.5);
|
||||
|
||||
let tb = graphops::sequence_time_base(&lock(&project).graph, seq).unwrap();
|
||||
let at = |frame: i64| graphops::ts_to_rational(frame, tb);
|
||||
let render = |montage: Vec<MontageClip>, adjustments: Vec<AdjustmentSpan>, at_time: Rational| {
|
||||
let params = VideoTicketParams {
|
||||
viewer: 0,
|
||||
project: String::new(),
|
||||
time: at_time,
|
||||
force_size: Some((64, 64)),
|
||||
force_format: Some(oak_core::PixelFormat::F32),
|
||||
cache: None,
|
||||
cache_dir: None,
|
||||
cache_id: None,
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage,
|
||||
adjustments,
|
||||
};
|
||||
let mut dst = vec![0u8; 64 * 64 * 16];
|
||||
oak_render::eval::render_montage_frame_into(
|
||||
params.time,
|
||||
¶ms,
|
||||
(64, 64),
|
||||
&mut dst,
|
||||
64 * 16,
|
||||
)
|
||||
.expect("montage render");
|
||||
dst
|
||||
};
|
||||
let pixel = |frame: &[u8]| {
|
||||
let off = (8 * 64 + 8) * 16;
|
||||
[0, 1, 2, 3]
|
||||
.map(|i| f32::from_le_bytes(frame[off + i * 4..off + i * 4 + 4].try_into().unwrap()))
|
||||
};
|
||||
|
||||
// Inside the layer's range the span rides the ticket, above the stack.
|
||||
let (montage, spans) = video_montage_with_adjustments(&project, seq, at(5));
|
||||
assert_eq!(montage.len(), 2, "both clips cover frame 5");
|
||||
assert_eq!(spans.len(), 1, "the layer emits one span");
|
||||
assert_eq!(
|
||||
spans[0].track_index,
|
||||
montage.len(),
|
||||
"the layer sits above every clip (it grades the whole composite)"
|
||||
);
|
||||
assert_eq!(spans[0].effects.len(), 1, "the layer's stack rides the span");
|
||||
assert_eq!(spans[0].effects[0].type_id, "org.olivevideoeditor.Olive.opacity");
|
||||
|
||||
let baseline = render(montage.clone(), Vec::new(), at(5));
|
||||
let graded = render(montage.clone(), spans, at(5));
|
||||
let (b, g) = (pixel(&baseline), pixel(&graded));
|
||||
assert!(
|
||||
b[2] > 0.6 && b[3] > 0.9,
|
||||
"the baseline shows V2's opaque blue on top (b={}, a={})",
|
||||
b[2],
|
||||
b[3]
|
||||
);
|
||||
for i in 0..4 {
|
||||
assert!(
|
||||
(g[i] - b[i] * 0.5).abs() < 0.06,
|
||||
"the layer halves channel {i} ({}, expected {})",
|
||||
g[i],
|
||||
b[i] * 0.5
|
||||
);
|
||||
}
|
||||
|
||||
// Outside the layer's range no span is emitted and the render is
|
||||
// byte-for-byte the plain montage.
|
||||
for frame in [1, 8] {
|
||||
let (m, s) = video_montage_with_adjustments(&project, seq, at(frame));
|
||||
assert_eq!(m.len(), 2, "both clips still cover frame {frame}");
|
||||
assert!(s.is_empty(), "frame {frame} is outside the layer's range");
|
||||
assert_eq!(
|
||||
render(m.clone(), s, at(frame)),
|
||||
render(m, Vec::new(), at(frame)),
|
||||
"frame {frame} renders ungraded"
|
||||
);
|
||||
}
|
||||
|
||||
// A disabled layer is not a layer at all: no span, and the frame is
|
||||
// the plain composite (the graph path's enabled check).
|
||||
{
|
||||
let mut g = lock(&project);
|
||||
let entry = g.graph.get_mut(adj).expect("the layer block");
|
||||
entry
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.and_then(|a| a.downcast_mut::<oak_node::block::AdjustmentBlockBehavior>())
|
||||
.expect("an adjustment behavior")
|
||||
.core
|
||||
.enabled = false;
|
||||
}
|
||||
let (m, s) = video_montage_with_adjustments(&project, seq, at(5));
|
||||
assert_eq!(m.len(), 2, "the clips stay in the montage");
|
||||
assert!(s.is_empty(), "a disabled layer emits no span");
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
let _ = std::fs::remove_file(&red);
|
||||
let _ = std::fs::remove_file(&blue);
|
||||
}
|
||||
|
||||
/// M14 W3 montage path: a layer BELOW a clip grades only the tracks
|
||||
/// underneath it — the span's boundary is the montage index reached
|
||||
/// when the clips under the layer are composited, so V3's clip lands
|
||||
/// over the graded result instead of being graded itself.
|
||||
#[test]
|
||||
fn montage_adjustment_span_grades_only_the_tracks_below_it() {
|
||||
let _media = media_lock();
|
||||
oak_core::color::set_pipeline_color_settings(
|
||||
oak_core::colormath::WorkingColorSpace::SrgbLegacy,
|
||||
oak_core::colormath::OutputColorSpec::default(),
|
||||
);
|
||||
oak_undo::global::clear().unwrap();
|
||||
let red = std::env::temp_dir().join(format!("oakapp_adjlow_red_{}.mp4", std::process::id()));
|
||||
let blue =
|
||||
std::env::temp_dir().join(format!("oakapp_adjlow_blue_{}.mp4", std::process::id()));
|
||||
oak_codec::testmedia::write_test_clip_solid(&red, 64, 64, 10, 10, [0.9, 0.1, 0.1, 1.0])
|
||||
.expect("generate the red media");
|
||||
oak_codec::testmedia::write_test_clip_solid(&blue, 64, 64, 10, 10, [0.1, 0.1, 0.9, 1.0])
|
||||
.expect("generate the blue media");
|
||||
|
||||
let project = graphops::create_project();
|
||||
let seq = graphops::create_sequence(&project, "Mid Adjustment Montage");
|
||||
let red_footage = graphops::import_footage(&project, &red).expect("import the red media");
|
||||
let blue_footage = graphops::import_footage(&project, &blue).expect("import the blue media");
|
||||
graphops::place_footage_clip(&project, seq, red_footage, TrackType::Video, 0, 0, 10, 0)
|
||||
.expect("place the V1 (red) clip");
|
||||
graphops::add_track(&project, seq, TrackType::Video).expect("add the adjustment track");
|
||||
let adj_track = {
|
||||
let g = lock(&project);
|
||||
graphops::track_ids(&g.graph, seq, TrackType::Video)[1]
|
||||
};
|
||||
let (_adj, _fx) = adjustment_layer_with_opacity(&project, seq, adj_track, 0, 5, 0.5);
|
||||
// V3's clip carries its own 50% Opacity so the composite keeps both
|
||||
// contributions visible (an opaque top clip would hide the grading).
|
||||
let blue_clip =
|
||||
graphops::place_footage_clip(&project, seq, blue_footage, TrackType::Video, 2, 0, 10, 0)
|
||||
.expect("place the V3 (blue) clip");
|
||||
let clip_fx = crate::oakui::effectchain::insert(
|
||||
&project,
|
||||
blue_clip,
|
||||
usize::MAX,
|
||||
"org.olivevideoeditor.Olive.opacity",
|
||||
)
|
||||
.expect("append the clip's opacity effect");
|
||||
crate::oakui::effectchain::set_input_value(
|
||||
&project,
|
||||
clip_fx,
|
||||
"opacity_in",
|
||||
oak_node::value::NodeValue::Float(0.5),
|
||||
)
|
||||
.expect("set the clip opacity value");
|
||||
|
||||
let tb = graphops::sequence_time_base(&lock(&project).graph, seq).unwrap();
|
||||
let time = graphops::ts_to_rational(0, tb);
|
||||
let render = |montage: Vec<MontageClip>, adjustments: Vec<AdjustmentSpan>, at_time: Rational| {
|
||||
let params = VideoTicketParams {
|
||||
viewer: 0,
|
||||
project: String::new(),
|
||||
time: at_time,
|
||||
force_size: Some((64, 64)),
|
||||
force_format: Some(oak_core::PixelFormat::F32),
|
||||
cache: None,
|
||||
cache_dir: None,
|
||||
cache_id: None,
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage,
|
||||
adjustments,
|
||||
};
|
||||
let mut dst = vec![0u8; 64 * 64 * 16];
|
||||
oak_render::eval::render_montage_frame_into(
|
||||
at_time,
|
||||
¶ms,
|
||||
(64, 64),
|
||||
&mut dst,
|
||||
64 * 16,
|
||||
)
|
||||
.expect("montage render");
|
||||
dst
|
||||
};
|
||||
let pixel = |frame: &[u8]| {
|
||||
let off = (8 * 64 + 8) * 16;
|
||||
[0, 1, 2, 3]
|
||||
.map(|i| f32::from_le_bytes(frame[off + i * 4..off + i * 4 + 4].try_into().unwrap()))
|
||||
};
|
||||
|
||||
let (montage, spans) = video_montage_with_adjustments(&project, seq, time);
|
||||
// V2 is taken over by the layer (no clips there), so the montage is
|
||||
// V1's red then V3's blue, and the boundary sits between them.
|
||||
assert_eq!(montage.len(), 2, "the two clips cover frame 0");
|
||||
assert!(montage[0].effects.is_empty(), "the red clip has no stack");
|
||||
assert_eq!(montage[1].effects.len(), 1, "the blue clip's own stack rides it");
|
||||
assert_eq!(spans.len(), 1, "the layer emits one span");
|
||||
assert_eq!(spans[0].track_index, 1, "the layer grades only V1's clip");
|
||||
assert!(!spans[0].effects.is_empty(), "the layer carries its opacity");
|
||||
|
||||
let baseline = pixel(&render(montage.clone(), Vec::new(), time));
|
||||
let graded = pixel(&render(montage.clone(), spans.clone(), time));
|
||||
// Blue at 50% alpha over red: r = 0.05*0.5 + 0.9*0.5 = 0.475,
|
||||
// a = 0.5 + 1.0*0.5 = 1.0. With the layer the red underneath is
|
||||
// halved first: r = 0.05*0.5 + 0.45*0.5 = 0.25, a = 0.5 + 0.5*0.5 = 0.75.
|
||||
assert!(
|
||||
(baseline[0] - 0.475).abs() < 0.06 && (baseline[3] - 1.0).abs() < 0.06,
|
||||
"the plain composite is blue over red (r={}, a={})",
|
||||
baseline[0],
|
||||
baseline[3]
|
||||
);
|
||||
assert!(
|
||||
(graded[0] - 0.25).abs() < 0.06,
|
||||
"only the red underneath is halved (r={}, expected 0.25)",
|
||||
graded[0]
|
||||
);
|
||||
assert!(
|
||||
(graded[3] - 0.75).abs() < 0.06,
|
||||
"the layer's alpha halving shows through the 50% top clip (a={}, expected 0.75)",
|
||||
graded[3]
|
||||
);
|
||||
|
||||
// The layer is a layer only through the range it covers: past frame 5
|
||||
// the clips still composite but no span is emitted, and a stale span
|
||||
// (one carried outside its range) is inert.
|
||||
let late = graphops::ts_to_rational(6, tb);
|
||||
let stale = spans.clone();
|
||||
let (m, s) = video_montage_with_adjustments(&project, seq, late);
|
||||
assert_eq!(m.len(), 2, "the clips still cover frame 6");
|
||||
assert!(s.is_empty(), "the layer's range ends at frame 5");
|
||||
assert_eq!(m[0].effects.len(), 0, "the red clip still has no stack");
|
||||
let stale_render = render(m.clone(), stale, late);
|
||||
assert_eq!(
|
||||
stale_render,
|
||||
render(m, Vec::new(), late),
|
||||
"a span whose range misses the ticket's time renders ungraded"
|
||||
);
|
||||
|
||||
oak_undo::global::clear().unwrap();
|
||||
let _ = std::fs::remove_file(&red);
|
||||
let _ = std::fs::remove_file(&blue);
|
||||
}
|
||||
|
||||
/// A multi-cam HOST clip's montage media is the CURRENT source's angle
|
||||
/// clip (footage + media-in mapped from the host position) — without
|
||||
/// this the host clip's texture chain has no footage and the sequence
|
||||
@@ -1662,6 +2076,7 @@ mod tests {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage,
|
||||
adjustments: Vec::new(),
|
||||
};
|
||||
let mut dst = vec![0u8; 64 * 64 * 16];
|
||||
oak_render::eval::render_montage_frame_into(
|
||||
@@ -1872,6 +2287,7 @@ mod tests {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage,
|
||||
adjustments: Vec::new(),
|
||||
};
|
||||
let mut dst = vec![0u8; 64 * 64 * 16];
|
||||
oak_render::eval::render_montage_frame_into(
|
||||
|
||||
@@ -135,6 +135,12 @@ pub trait PanelCommandHandler: Sized {
|
||||
fn speed_duration(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
/// The clip menu's "Default Transition" item / the Ctrl+Shift+D action:
|
||||
/// add a transition of the configured default length at every seam
|
||||
/// around the selected clips.
|
||||
fn default_transition(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn toggle_links(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -259,6 +265,7 @@ pub fn dispatch_to<P: PanelCommandHandler>(
|
||||
ActionId::RippleDelete => panel.ripple_delete(cx),
|
||||
ActionId::SplitAtPlayhead => panel.split_at_playhead(cx),
|
||||
ActionId::SpeedDuration => panel.speed_duration(cx),
|
||||
ActionId::DefaultTransition => panel.default_transition(cx),
|
||||
ActionId::LinkUnlink => panel.toggle_links(cx),
|
||||
ActionId::EnableDisable => panel.toggle_selected_enabled(cx),
|
||||
ActionId::Insert => panel.insert(cx),
|
||||
|
||||
@@ -103,6 +103,11 @@ pub struct TimelinePanel<E: AppEngine> {
|
||||
/// The track behind the currently open track-head menu (the "Delete"
|
||||
/// item's target); `None` when a different menu is open.
|
||||
context_track: Option<usize>,
|
||||
/// The empty-area click behind the currently open empty-area menu: the
|
||||
/// display track under the cursor plus the clicked frame ("Add
|
||||
/// Adjustment Layer" anchors there); `None` when a different menu is
|
||||
/// open.
|
||||
context_empty: Option<(usize, Frame)>,
|
||||
}
|
||||
|
||||
/// A footage drop target resolved from the cursor: the display track under
|
||||
@@ -198,6 +203,7 @@ impl<E: AppEngine> TimelinePanel<E> {
|
||||
footage_drop: None,
|
||||
context_menu,
|
||||
context_track: None,
|
||||
context_empty: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +246,10 @@ impl<E: AppEngine> TimelinePanel<E> {
|
||||
};
|
||||
clip_menu(sync, &proxy, Some(multicam))
|
||||
}
|
||||
TimelineHit::Empty { .. } => empty_area_menu(),
|
||||
TimelineHit::Empty { track, frame } => {
|
||||
self.context_empty = Some((*track, *frame));
|
||||
empty_area_menu()
|
||||
}
|
||||
TimelineHit::TrackHead(track) => {
|
||||
self.context_track = Some(*track);
|
||||
track_head_menu()
|
||||
@@ -251,6 +260,9 @@ impl<E: AppEngine> TimelinePanel<E> {
|
||||
if !matches!(hit, TimelineHit::TrackHead(_)) {
|
||||
self.context_track = None;
|
||||
}
|
||||
if !matches!(hit, TimelineHit::Empty { .. }) {
|
||||
self.context_empty = None;
|
||||
}
|
||||
self.context_menu.show(position, menu, cx);
|
||||
}
|
||||
|
||||
@@ -282,6 +294,16 @@ impl<E: AppEngine> TimelinePanel<E> {
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.delete_empty_tracks(cx));
|
||||
}
|
||||
LOCAL_ADD_ADJUSTMENT_LAYER => {
|
||||
if let Some((track, frame)) = self.context_empty {
|
||||
if let Err(err) = self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.add_adjustment_layer(track, frame, cx))
|
||||
{
|
||||
println!("[timeline] add adjustment layer failed: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
LOCAL_CACHE_ALL | LOCAL_CACHE_IN_OUT | LOCAL_CACHE_DISCARD => {
|
||||
println!("[timeline] cache action {item} (not implemented yet)");
|
||||
}
|
||||
@@ -667,6 +689,18 @@ impl<E: AppEngine> PanelCommandHandler for TimelinePanel<E> {
|
||||
.update(cx, |engine, cx| engine.split_at_playhead(cx));
|
||||
true
|
||||
}
|
||||
/// 编辑 → 设为默认转场 / Ctrl+Shift+D: adds a transition of the
|
||||
/// configured default length at every seam around the selected clips.
|
||||
fn default_transition(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let ids: Vec<ClipId> = self.timeline.read(cx).selection().iter().copied().collect();
|
||||
let result = self
|
||||
.engine
|
||||
.update(cx, |engine, cx| engine.add_default_transition(ids, cx));
|
||||
if let Err(error) = result {
|
||||
println!("[timeline] add default transition failed: {error}");
|
||||
}
|
||||
true
|
||||
}
|
||||
fn set_marker(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.add_marker_at_playhead(cx));
|
||||
@@ -1109,6 +1143,7 @@ const LOCAL_TIMECODE_FRAMES: usize = 2126;
|
||||
const LOCAL_TIMECODE_MILLISECONDS: usize = 2127;
|
||||
const LOCAL_ADD_VIDEO_TRACK: usize = 2130;
|
||||
const LOCAL_ADD_AUDIO_TRACK: usize = 2131;
|
||||
const LOCAL_ADD_ADJUSTMENT_LAYER: usize = 2132;
|
||||
|
||||
/// A registry-backed item shown under a "Properties" label (the C++ clip
|
||||
/// and sequence "Properties" entries open the Speed/Duration and Sequence
|
||||
@@ -1294,7 +1329,11 @@ pub(crate) fn empty_area_menu() -> Menu {
|
||||
LOCAL_SHOW_WAVEFORMS,
|
||||
i18n::tr("timeline.context.show_waveforms"),
|
||||
)
|
||||
.with_checked(false)
|
||||
.with_checked(false),
|
||||
MenuItem::new(
|
||||
LOCAL_ADD_ADJUSTMENT_LAYER,
|
||||
i18n::tr("timeline.context.add_adjustment_layer"),
|
||||
)
|
||||
.separated(),
|
||||
properties_item(ActionId::SequenceSettings),
|
||||
])
|
||||
@@ -1376,7 +1415,8 @@ pub(crate) fn ruler_menu() -> Menu {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::{px, size, TestAppContext, VisualTestContext};
|
||||
use gpui::timeline::{FrameRange, TimelineDataSource};
|
||||
use gpui::{px, size, Hsla, TestAppContext, VisualTestContext};
|
||||
|
||||
/// Builds a `TimelinePanel` in a window of the given logical size and
|
||||
/// returns a `VisualTestContext` for bounds assertions.
|
||||
@@ -1710,8 +1750,9 @@ mod tests {
|
||||
assert!(!item.checked.unwrap_or(false));
|
||||
}
|
||||
|
||||
/// The empty-area menu exposes the view toggles plus the sequence
|
||||
/// settings "Properties" entry.
|
||||
/// The empty-area menu exposes the view toggles, the "Add Adjustment
|
||||
/// Layer" creation entry (§3.5) and the sequence settings "Properties"
|
||||
/// entry.
|
||||
#[test]
|
||||
fn empty_area_menu_toggles_and_properties() {
|
||||
let menu = empty_area_menu();
|
||||
@@ -1732,10 +1773,89 @@ mod tests {
|
||||
);
|
||||
assert!(sub.iter().all(|item| item.checked == Some(false)));
|
||||
|
||||
// "Add Adjustment Layer" sits between the view toggles and
|
||||
// Properties, carries the i18n label and closes its section so the
|
||||
// tail reads as the properties block.
|
||||
let adjustment = menu
|
||||
.items
|
||||
.iter()
|
||||
.position(|item| item.id == LOCAL_ADD_ADJUSTMENT_LAYER)
|
||||
.expect("add adjustment layer item");
|
||||
let item = &menu.items[adjustment];
|
||||
assert!(item.label == i18n::tr("timeline.context.add_adjustment_layer"));
|
||||
assert!(item.enabled);
|
||||
assert!(item.separator_after);
|
||||
assert_eq!(
|
||||
menu.items[adjustment + 1].id,
|
||||
ActionId::SequenceSettings.entry().menu_id(),
|
||||
"Properties follows the adjustment item"
|
||||
);
|
||||
|
||||
let properties = menu.items.last().expect("properties tail");
|
||||
assert_eq!(properties.id, ActionId::SequenceSettings.entry().menu_id());
|
||||
}
|
||||
|
||||
/// Choosing "Add Adjustment Layer" from the empty-area menu lands the
|
||||
/// demo's five-second layer on the video track under the cursor, at the
|
||||
/// clicked frame and in a color of its own (§3.5).
|
||||
#[gpui::test]
|
||||
async fn empty_area_menu_adds_an_adjustment_layer_at_the_clicked_frame(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
let (cx, panel) = panel_window(cx, 1600.0, 900.0);
|
||||
// The demo's track 0 is a video track with one clip (标题.mov, frames
|
||||
// 120..300); the created layer must not borrow that clip's color.
|
||||
let demo_colors: Vec<Hsla> = cx.read(|app| {
|
||||
let engine = panel.read(app).engine.clone();
|
||||
let engine = engine.read(app);
|
||||
engine
|
||||
.track(0)
|
||||
.expect("the demo's first track")
|
||||
.clips()
|
||||
.iter()
|
||||
.filter_map(|clip| clip.color())
|
||||
.collect()
|
||||
});
|
||||
assert_eq!(demo_colors.len(), 1, "the demo track has one colored clip");
|
||||
|
||||
// The right-click anchors the menu on (track 0, frame 50); choosing
|
||||
// the item creates the layer there.
|
||||
cx.update(|_window, cx| {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.open_context_menu(
|
||||
gpui::point(px(10.0), px(10.0)),
|
||||
TimelineHit::Empty {
|
||||
track: 0,
|
||||
frame: Frame(50),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
panel.on_local_menu_item(LOCAL_ADD_ADJUSTMENT_LAYER, cx);
|
||||
});
|
||||
});
|
||||
|
||||
cx.read(|app| {
|
||||
let engine = panel.read(app).engine.clone();
|
||||
let engine = engine.read(app);
|
||||
let track = engine.track(0).expect("the demo's first track");
|
||||
let clips = track.clips();
|
||||
assert_eq!(clips.len(), 2, "the layer joins the track's demo clip");
|
||||
let layer = &clips[0];
|
||||
assert_eq!(
|
||||
layer.range(),
|
||||
FrameRange::new(Frame(50), Frame(175)),
|
||||
"five seconds (125 frames at 25fps) from the clicked frame"
|
||||
);
|
||||
assert_eq!(layer.label(), SharedString::from("调整图层"));
|
||||
assert_eq!(layer.media_in(), Frame(0));
|
||||
let color = layer.color().expect("the layer carries its own color");
|
||||
assert!(
|
||||
demo_colors.iter().all(|demo| *demo != color),
|
||||
"the layer color is distinct from the demo clips"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// The track-header menu is exactly the two delete entries.
|
||||
#[test]
|
||||
fn track_head_menu_offers_add_then_delete_entries() {
|
||||
|
||||
@@ -657,6 +657,7 @@ pub fn render_frame(
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage,
|
||||
adjustments: Vec::new(),
|
||||
};
|
||||
let id = m.tickets.next_id();
|
||||
m.tickets.submit_video_with_id(id, params, Box::new(|_| {}));
|
||||
|
||||
@@ -136,6 +136,16 @@ pub struct TransitionBlockBehavior {
|
||||
pub out_offset: Rational,
|
||||
}
|
||||
|
||||
/// Adjustment block behavior: a block spanning a timeline range that hosts
|
||||
/// an effect chain (the "adjustment layer"). Its effect chain is attached
|
||||
/// exactly like a clip's, through `tex_in`; the source texture is supplied
|
||||
/// by the renderer (the composite of the tracks below), so `value()` only
|
||||
/// passes a connected texture through and a bare adjustment block is inert.
|
||||
pub struct AdjustmentBlockBehavior {
|
||||
/// Block core.
|
||||
pub core: BlockCore,
|
||||
}
|
||||
|
||||
/// ClipBlock input ids (C++ `clip.cpp`).
|
||||
pub mod clip_input {
|
||||
/// `tex_in` (texture, static) — the clip's effect input (C++
|
||||
@@ -160,6 +170,18 @@ pub mod transition_input {
|
||||
pub const OUT_BLOCK: &str = "out_block_in";
|
||||
/// `in_block_in` (the incoming side).
|
||||
pub const IN_BLOCK: &str = "in_block_in";
|
||||
/// The transition style combo (`crate::nodes::transitions` owns the
|
||||
/// names and the shader ids they map to). Not part of the C++ input
|
||||
/// set — the C++ transition block is cross-dissolve only.
|
||||
pub const TYPE_INPUT: &str = "type_in";
|
||||
}
|
||||
|
||||
/// AdjustmentBlock input ids.
|
||||
pub mod adjustment_input {
|
||||
/// `tex_in` (texture, static) — the adjustment layer's effect input
|
||||
/// (same id every effect node and the clip use, so the effect chain
|
||||
/// machinery works unchanged).
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
}
|
||||
|
||||
/// Save the shared [`BlockCore`] custom fields (C++ persists the
|
||||
@@ -270,6 +292,15 @@ impl TransitionBlockBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
impl AdjustmentBlockBehavior {
|
||||
/// New adjustment layer with a default length of one second.
|
||||
pub fn new() -> Self {
|
||||
AdjustmentBlockBehavior {
|
||||
core: BlockCore::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn block_categories() -> &'static [Category] {
|
||||
&[Category::Timeline]
|
||||
}
|
||||
@@ -498,8 +529,10 @@ impl NodeBehavior for TransitionBlockBehavior {
|
||||
true
|
||||
}
|
||||
|
||||
/// No video output yet (C++ transition crossfades are not ported; a
|
||||
/// transition renders as a hole for now).
|
||||
/// No video output of its own: the renderer blends the two blocks
|
||||
/// this one joins at the track level (`oak_render::eval`'s transition
|
||||
/// step), so the block node itself never produces a texture and a
|
||||
/// track carrying only a transition renders as a hole.
|
||||
fn value(
|
||||
&self,
|
||||
_core: &NodeCore,
|
||||
@@ -510,6 +543,75 @@ impl NodeBehavior for TransitionBlockBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for AdjustmentBlockBehavior {
|
||||
fn name(&self) -> &str {
|
||||
"Adjustment Layer"
|
||||
}
|
||||
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.adjustment"
|
||||
}
|
||||
|
||||
fn categories(&self) -> &[Category] {
|
||||
block_categories()
|
||||
}
|
||||
|
||||
fn as_any(&self) -> Option<&dyn std::any::Any> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(AdjustmentBlockBehavior {
|
||||
core: self.core.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Custom project save: the shared block span/state only (the effect
|
||||
/// chain lives in the graph edges, like a clip's).
|
||||
fn save_custom(&self, core: &NodeCore, writer: &mut dyn crate::serializer::XmlWrite) {
|
||||
let _ = core;
|
||||
save_block_core(writer, &self.core);
|
||||
}
|
||||
|
||||
/// Custom project load; the track reference resolves in the
|
||||
/// serializer's post-load pass.
|
||||
fn load_custom(
|
||||
&mut self,
|
||||
_core: &mut NodeCore,
|
||||
reader: &mut dyn crate::serializer::XmlRead,
|
||||
) -> bool {
|
||||
load_block_core(reader, &mut self.core, &mut |_, _| false);
|
||||
true
|
||||
}
|
||||
|
||||
/// Pass the connected texture through (same shape as the clip's
|
||||
/// `value()`): an unconnected `tex_in` yields nothing, so a bare
|
||||
/// adjustment layer is inert. The renderer drives the adjustment by
|
||||
/// feeding the lower composition into the head of the effect chain.
|
||||
fn value(
|
||||
&self,
|
||||
_core: &NodeCore,
|
||||
inputs: &NodeValueRow,
|
||||
_time: Rational,
|
||||
table: &mut NodeValueTable,
|
||||
) {
|
||||
if !self.core.enabled {
|
||||
return;
|
||||
}
|
||||
let Some(value) = inputs.get(adjustment_input::TEXTURE_INPUT) else {
|
||||
return;
|
||||
};
|
||||
// `NodeValue::clone` addrefs the texture handle so the table row
|
||||
// owns its own reference (released on drop); a plain handle copy
|
||||
// would double-release the input's reference.
|
||||
table.push(ValueType::Texture, value.clone(), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor for a clip block (C++ `ClipBlock::ClipBlock()`): adds the
|
||||
/// static clip inputs (`media_in_in`, `speed_in`, `reverse_in`,
|
||||
/// `maintain_audio_pitch_in`, `autocache_in`, `loop_in`) and the texture
|
||||
@@ -583,7 +685,10 @@ pub fn gap_create() -> (NodeCore, Box<dyn NodeBehavior>) {
|
||||
}
|
||||
|
||||
/// Constructor for a transition block (C++ `TransitionBlock`): adds the
|
||||
/// `out_block_in`/`in_block_in` node-typed connection inputs.
|
||||
/// `out_block_in`/`in_block_in` node-typed connection inputs plus the
|
||||
/// transition style combo (the C++ block is cross-dissolve only; the
|
||||
/// combo lets the composite driver pick between the four shaders in
|
||||
/// [`crate::nodes::transitions`]).
|
||||
pub fn transition_create() -> (NodeCore, Box<dyn NodeBehavior>) {
|
||||
let mut core = NodeCore::new();
|
||||
let mut out = Input::new(
|
||||
@@ -604,9 +709,43 @@ pub fn transition_create() -> (NodeCore, Box<dyn NodeBehavior>) {
|
||||
inn.display_name = "To".to_string();
|
||||
core.add_input(inn);
|
||||
|
||||
let mut ty = Input::new(
|
||||
transition_input::TYPE_INPUT,
|
||||
ValueType::Combo,
|
||||
NodeValue::Combo(0),
|
||||
);
|
||||
ty.flags |= crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
|
||||
ty.properties = vec![(
|
||||
"combobox_strings".to_string(),
|
||||
NodeValue::Binary(crate::nodes::transitions::TYPE_NAMES.join(",").into_bytes()),
|
||||
)];
|
||||
core.add_input(ty);
|
||||
|
||||
(core, Box::new(TransitionBlockBehavior::new()))
|
||||
}
|
||||
|
||||
/// Constructor for an adjustment block (the "adjustment layer"): a single
|
||||
/// connectable, non-keyframable `tex_in` texture input that doubles as the
|
||||
/// block's effect input. No footage link and no media/speed/reverse inputs
|
||||
/// — the block owns only its timeline span.
|
||||
pub fn adjustment_create() -> (NodeCore, Box<dyn NodeBehavior>) {
|
||||
let mut core = NodeCore::new();
|
||||
|
||||
// Same convention as `clip_create`: the texture input sits right after
|
||||
// the inherited `enabled_in` so the effect chain attaches at position
|
||||
// one and the renderer can swap the whole chain in and out.
|
||||
let mut tex = Input::new(
|
||||
adjustment_input::TEXTURE_INPUT,
|
||||
ValueType::Texture,
|
||||
NodeValue::None,
|
||||
);
|
||||
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
|
||||
core.inputs.insert(1, tex);
|
||||
core.effect_input = adjustment_input::TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(AdjustmentBlockBehavior::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -770,4 +909,71 @@ mod tests {
|
||||
Some(effect_id)
|
||||
);
|
||||
}
|
||||
|
||||
/// The adjustment layer declares one connectable, non-keyframable
|
||||
/// `tex_in` texture input and uses it as the effect input (the clip's
|
||||
/// convention, without the media/speed inputs).
|
||||
#[test]
|
||||
fn adjustment_effect_input_and_texture_input() {
|
||||
let (core, behavior) = adjustment_create();
|
||||
assert_eq!(behavior.name(), "Adjustment Layer");
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.adjustment");
|
||||
assert_eq!(core.effect_input, adjustment_input::TEXTURE_INPUT);
|
||||
|
||||
let tex = core
|
||||
.get_input(adjustment_input::TEXTURE_INPUT)
|
||||
.expect("adjustment declares a texture input");
|
||||
assert_eq!(tex.value_type, ValueType::Texture);
|
||||
assert_eq!(tex.default, NodeValue::None);
|
||||
assert_ne!(tex.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
|
||||
assert!(
|
||||
tex.is_connectable(),
|
||||
"effects attach through the texture input"
|
||||
);
|
||||
|
||||
// `tex_in` sits right after the inherited `enabled_in`, and the
|
||||
// adjustment declares no further inputs.
|
||||
let ids: Vec<&str> = core.inputs.iter().map(|i| i.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![crate::node::ENABLED_INPUT, adjustment_input::TEXTURE_INPUT,]
|
||||
);
|
||||
}
|
||||
|
||||
/// The adjustment layer copies the connected `tex_in` texture to its
|
||||
/// output; disabled and unconnected adjustment layers emit nothing
|
||||
/// (the renderer feeds the chain's head when it drives the block).
|
||||
#[test]
|
||||
fn adjustment_value_passes_connected_texture_only() {
|
||||
let mut inputs = NodeValueRow::new();
|
||||
let handle = crate::handle::make_owned(43i32);
|
||||
inputs.insert(
|
||||
adjustment_input::TEXTURE_INPUT.to_string(),
|
||||
NodeValue::Texture(handle),
|
||||
);
|
||||
|
||||
let behavior = AdjustmentBlockBehavior::new();
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&NodeCore::empty(), &inputs, Rational::new(0, 1), &mut table);
|
||||
assert_eq!(table.count(), 1);
|
||||
let NodeValue::Texture(out) = table.get(ValueType::Texture).unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(out.ctx, handle.ctx, "the same texture box passes through");
|
||||
|
||||
let mut disabled = AdjustmentBlockBehavior::new();
|
||||
disabled.core.enabled = false;
|
||||
let mut table = NodeValueTable::default();
|
||||
disabled.value(&NodeCore::empty(), &inputs, Rational::new(0, 1), &mut table);
|
||||
assert_eq!(table.count(), 0, "disabled adjustment emits nothing");
|
||||
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(
|
||||
&NodeCore::empty(),
|
||||
&NodeValueRow::new(),
|
||||
Rational::new(0, 1),
|
||||
&mut table,
|
||||
);
|
||||
assert_eq!(table.count(), 0, "unconnected adjustment emits nothing");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Composite source (Rust-only render seam; no C++ counterpart).
|
||||
//!
|
||||
//! The graph renderer builds one of these per adjustment-layer flush: it
|
||||
//! prefills `tex_in` with the composite of the tracks below the layer
|
||||
//! (a standard value, so nothing has to be wired upstream) and connects
|
||||
//! the node to the head of the layer's effect chain for the duration of
|
||||
//! one [`oak_node::traverser::Traverser::evaluate`] pass. `value()`
|
||||
//! pushes that texture straight into the output table — the effect chain
|
||||
//! downstream sees an ordinary texture input, so nested shader jobs,
|
||||
//! plugin jobs and the pass-through case all work unchanged.
|
||||
//!
|
||||
//! It is hidden from the create menu (users never place one; the renderer
|
||||
//! owns its lifetime — see `oakrender::eval::render_graph_frame`) and has
|
||||
//! no shader of its own. It is deliberately absent from the factory table:
|
||||
//! `factory_smoke_test` pins that table to the C++ registration order, and
|
||||
//! this type has no C++ counterpart — the renderer reaches it through
|
||||
//! [`create`] directly.
|
||||
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Stable type id (`org.olivevideoeditor.Olive.composite_source`).
|
||||
pub const TYPE_ID: &str = "org.olivevideoeditor.Olive.composite_source";
|
||||
|
||||
/// Texture input id: the prefilled source texture (same id every effect
|
||||
/// node uses, so it can feed an effect chain's head input directly).
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// The texture source node. Has no member fields.
|
||||
pub struct CompositeSource;
|
||||
|
||||
impl NodeBehavior for CompositeSource {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Composite Source"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
TYPE_ID
|
||||
}
|
||||
|
||||
/// Categories (never used for menus — the node carries
|
||||
/// `DONT_SHOW_IN_CREATE_MENU`).
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Generator]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Supplies a renderer-composed texture to an effect chain."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "Texture".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "Texture",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: push the prefilled texture. The render driver
|
||||
/// sets it as the standard value of the (unconnected) `tex_in`, which
|
||||
/// the traverser's row builder hands over as an ordinary texture row
|
||||
/// entry; a bare node (no prefill) yields nothing.
|
||||
fn value(
|
||||
&self,
|
||||
_core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
_time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
let Some(value) = inputs.get(TEXTURE_INPUT) else {
|
||||
return;
|
||||
};
|
||||
// `NodeValue::clone` addrefs the texture handle so the table owns
|
||||
// its own reference (released on drop); a plain handle copy would
|
||||
// double-release the row's reference.
|
||||
table.push(crate::value::ValueType::Texture, value.clone(), None);
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(CompositeSource))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: one connectable, non-keyframable `tex_in` texture input
|
||||
/// (default `None` — the renderer always prefills it) and the
|
||||
/// create-menu hiding flag.
|
||||
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
|
||||
let mut core = NodeCore::new();
|
||||
let mut tex = crate::input::Input::new(
|
||||
TEXTURE_INPUT,
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::None,
|
||||
);
|
||||
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
|
||||
core.add_input(tex);
|
||||
core.flags |= crate::node::flags::DONT_SHOW_IN_CREATE_MENU;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
(core, Box::new(CompositeSource))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::input::flags::NOT_KEYFRAMABLE;
|
||||
use crate::node::flags::DONT_SHOW_IN_CREATE_MENU;
|
||||
use crate::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs_and_flags() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(behavior.type_id(), TYPE_ID);
|
||||
assert_eq!(behavior.name(), "Composite Source");
|
||||
let tex = core.get_input(TEXTURE_INPUT).expect("tex_in");
|
||||
assert_eq!(tex.value_type, ValueType::Texture);
|
||||
assert_eq!(tex.default, NodeValue::None);
|
||||
assert_eq!(tex.flags & NOT_KEYFRAMABLE, NOT_KEYFRAMABLE);
|
||||
assert_eq!(
|
||||
core.flags & DONT_SHOW_IN_CREATE_MENU,
|
||||
DONT_SHOW_IN_CREATE_MENU
|
||||
);
|
||||
assert_eq!(core.effect_input, TEXTURE_INPUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_names() {
|
||||
let n = CompositeSource;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "Texture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_pushes_row_texture() {
|
||||
let (core, behavior) = create();
|
||||
let texture = oak_core::texture::Texture::dummy();
|
||||
let inputs = NodeValueRow::from([(
|
||||
TEXTURE_INPUT.to_string(),
|
||||
NodeValue::Texture(crate::handle::make_owned(texture)),
|
||||
)]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, oak_core::Rational::new(0, 1), &mut table);
|
||||
assert!(matches!(
|
||||
table.get(ValueType::Texture),
|
||||
Some(NodeValue::Texture(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_without_texture_pushes_nothing() {
|
||||
let (core, behavior) = create();
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(
|
||||
&core,
|
||||
&NodeValueRow::new(),
|
||||
oak_core::Rational::new(0, 1),
|
||||
&mut table,
|
||||
);
|
||||
assert!(table.get(ValueType::Texture).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.type_id(), TYPE_ID);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
pub mod blur;
|
||||
mod chromakey;
|
||||
mod colordifferencekey;
|
||||
pub mod compositesource;
|
||||
mod cornerpindistortnode;
|
||||
mod cropdistortnode;
|
||||
mod despill;
|
||||
@@ -52,7 +53,7 @@ mod shapenodebase;
|
||||
mod solid;
|
||||
mod stroke;
|
||||
mod swirldistortnode;
|
||||
mod textbackend;
|
||||
pub mod textbackend;
|
||||
mod textv1;
|
||||
mod textv2;
|
||||
mod textv3;
|
||||
@@ -62,12 +63,35 @@ mod timeformat;
|
||||
mod timeinput;
|
||||
mod timeoffsetnode;
|
||||
mod timeremap;
|
||||
pub mod transitionfx;
|
||||
pub mod transitions;
|
||||
mod transformdistortnode;
|
||||
mod trigonometry;
|
||||
mod valuenode;
|
||||
mod volume;
|
||||
mod wavedistortnode;
|
||||
mod whitebalance;
|
||||
mod colormatrix;
|
||||
mod dilate;
|
||||
mod edgedetect;
|
||||
mod erode;
|
||||
mod colorcorrect;
|
||||
mod gamma;
|
||||
mod saturation;
|
||||
mod invert;
|
||||
mod clamp;
|
||||
mod grade;
|
||||
mod dirblur;
|
||||
mod sharpen;
|
||||
mod dissolve;
|
||||
mod keymix;
|
||||
mod premult;
|
||||
mod unpremult;
|
||||
mod position;
|
||||
mod mirror;
|
||||
mod checkerboard;
|
||||
mod colorbars;
|
||||
mod ramp;
|
||||
|
||||
use crate::node::{NodeBehavior, NodeCore};
|
||||
|
||||
@@ -123,6 +147,8 @@ pub fn register_all() {
|
||||
flipdistortnode::register(&mut meta);
|
||||
noise::register(&mut meta);
|
||||
timeoffsetnode::register(&mut meta);
|
||||
transitions::register(&mut meta);
|
||||
transitionfx::register(&mut meta);
|
||||
cornerpindistortnode::register(&mut meta);
|
||||
displaytransform::register(&mut meta);
|
||||
ociogradingtransformlinear::register(&mut meta);
|
||||
@@ -138,6 +164,30 @@ pub fn register_all() {
|
||||
tiledistortnode::register(&mut meta);
|
||||
swirldistortnode::register(&mut meta);
|
||||
rippledistortnode::register(&mut meta);
|
||||
colormatrix::register(&mut meta);
|
||||
dilate::register(&mut meta);
|
||||
edgedetect::register(&mut meta);
|
||||
erode::register(&mut meta);
|
||||
// The OpenFX-Misc cleanroom GPU ports (see
|
||||
// docs/zh/plans/ofx-misc-gpu-cleanroom.md), registered after the C++
|
||||
// menu order ends and before multicam.
|
||||
colorcorrect::register(&mut meta);
|
||||
gamma::register(&mut meta);
|
||||
saturation::register(&mut meta);
|
||||
invert::register(&mut meta);
|
||||
clamp::register(&mut meta);
|
||||
grade::register(&mut meta);
|
||||
dirblur::register(&mut meta);
|
||||
sharpen::register(&mut meta);
|
||||
dissolve::register(&mut meta);
|
||||
keymix::register(&mut meta);
|
||||
premult::register(&mut meta);
|
||||
unpremult::register(&mut meta);
|
||||
position::register(&mut meta);
|
||||
mirror::register(&mut meta);
|
||||
checkerboard::register(&mut meta);
|
||||
colorbars::register(&mut meta);
|
||||
ramp::register(&mut meta);
|
||||
multicamnode::register(&mut meta);
|
||||
|
||||
// OpenFX plugins have no static type ids (C++
|
||||
|
||||
@@ -0,0 +1,766 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Layer transition node: the adjustment-layer form of a transition
|
||||
//! (`docs/zh/plans/adjustment-layers-and-transitions.md` §4.2), the
|
||||
//! counterpart of the timeline transition *block* whose node lives in
|
||||
//! [`crate::nodes::transitions`].
|
||||
//!
|
||||
//! The node sits in the effect chain of an adjustment layer covering the
|
||||
//! seam. `tex_in` is the chain input (`core.effect_input`), so the render
|
||||
//! driver feeds it the composited lower layers; `blend_in` is the second
|
||||
//! texture — the pre-seam block's output by convention — bound by name
|
||||
//! from the job row, the same dual-texture mode [`crate::nodes::merge`]
|
||||
//! uses. [`crate::nodes::transitions`] boxes its job the same way; the
|
||||
//! difference is the chain input, which the timeline transition block
|
||||
//! does not have (its two sides arrive as a pair).
|
||||
//!
|
||||
//! `progress_in` is the layer's position across the adjustment block's
|
||||
//! own span. The renderer fills it in for the duration of an adjustment
|
||||
//! sweep (`oakrender::eval`'s `hooks.layer_progress`, which pre-fills the
|
||||
//! row of any job whose shader declares the uniform and whose params do
|
||||
//! not already carry it), so the node writes `progress_in` into the job
|
||||
//! row **only when the user set it explicitly** — a row carrying the
|
||||
//! default `0.0` would otherwise pin the node to the very start of the
|
||||
//! span. [`TransitionFxNode::value`] documents the "explicit" test.
|
||||
//!
|
||||
//! One behavior carries all four styles ([`TYPE_NAMES`]), dispatched by
|
||||
//! the `type_in` combo through its shader id ([`SHADER_IDS`]) — the
|
||||
//! `node->get_shader_code(shader_id)` path the math nodes and the
|
||||
//! timeline transition node use. The fragment sources are the timeline
|
||||
//! node's, adapted to the one-picture chain form: the fade mixes against
|
||||
//! its own `color_in` (the layer form has a single picture, so there is
|
||||
//! no dip between two of them), and the wipe/slide styles take a
|
||||
//! `direction_in` sweep direction.
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Chain texture input id. Type: texture; flags: not-keyframable. This
|
||||
/// is the node's effect input.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Second texture input id (the pre-seam picture by convention). Type:
|
||||
/// texture; flags: not-keyframable.
|
||||
pub const BLEND_INPUT: &str = "blend_in";
|
||||
|
||||
/// Transition progress input id. Type: float; default `0.0`;
|
||||
/// properties: `min = 0.0`, `max = 1.0`, `view = percentage`. `0.0`
|
||||
/// yields `tex_in`, `1.0` yields `blend_in`. Left out of the job row
|
||||
/// unless the user set it, so the renderer can fill in the adjustment
|
||||
/// layer's progress instead.
|
||||
pub const PROGRESS_INPUT: &str = "progress_in";
|
||||
|
||||
/// Fade target color input id. Type: color; default opaque black. Only
|
||||
/// the fade style samples it (`progress_in = 0` is all `color_in`,
|
||||
/// `1` is the input untouched).
|
||||
pub const COLOR_INPUT: &str = "color_in";
|
||||
|
||||
/// Wipe/slide sweep direction input id. Type: combo; flags:
|
||||
/// not-connectable, not-keyframable; properties: `combobox_strings` =
|
||||
/// [`DIRECTION_NAMES`].
|
||||
pub const DIRECTION_INPUT: &str = "direction_in";
|
||||
|
||||
/// Transition style input id. Type: combo; flags: not-connectable,
|
||||
/// not-keyframable; properties: `combobox_strings` = [`TYPE_NAMES`].
|
||||
pub const TYPE_INPUT: &str = "type_in";
|
||||
|
||||
/// Localized style names, in combo-index order.
|
||||
pub const TYPE_NAMES: [&str; 4] = ["Cross Dissolve", "Fade", "Wipe", "Slide"];
|
||||
|
||||
/// Shader ids for [`TYPE_NAMES`], by combo index. The strings match the
|
||||
/// timeline transition block's (`crate::nodes::transitions::SHADER_IDS`),
|
||||
/// so both transition forms ask the factory for the same style.
|
||||
pub const SHADER_IDS: [&str; 4] = ["crossdissolve", "fade", "wipe", "slide"];
|
||||
|
||||
/// Sweep direction names for [`DIRECTION_INPUT`], in combo-index order:
|
||||
/// the side the boundary sweeps towards. The incoming picture is
|
||||
/// revealed from the opposite edge (`Left to Right` wipes the `blend_in`
|
||||
/// picture in from the left, `blend_in` replacing `tex_in` behind the
|
||||
/// boundary).
|
||||
pub const DIRECTION_NAMES: [&str; 4] = [
|
||||
"Left to Right",
|
||||
"Right to Left",
|
||||
"Top to Bottom",
|
||||
"Bottom to Top",
|
||||
];
|
||||
|
||||
/// The shader id for a combo index, clamping out-of-range values onto
|
||||
/// cross dissolve (index `0`), the C++ default transition.
|
||||
pub fn shader_id_for(index: i64) -> &'static str {
|
||||
SHADER_IDS[index.clamp(0, SHADER_IDS.len() as i64 - 1) as usize]
|
||||
}
|
||||
|
||||
/// Cross dissolve: a straight mix, `50/50` at the midpoint.
|
||||
const SHADER_CROSSDISSOLVE: &str = r#"uniform sampler2D tex_in;
|
||||
uniform sampler2D blend_in;
|
||||
uniform bool tex_in_enabled;
|
||||
uniform bool blend_in_enabled;
|
||||
uniform float progress_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main(void) {
|
||||
if (!tex_in_enabled && !blend_in_enabled) {
|
||||
frag_color = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tex_in_enabled) {
|
||||
frag_color = texture(blend_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!blend_in_enabled) {
|
||||
frag_color = texture(tex_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
vec4 tex_col = texture(tex_in, ove_texcoord);
|
||||
vec4 blend_col = texture(blend_in, ove_texcoord);
|
||||
|
||||
frag_color = mix(tex_col, blend_col, progress_in);
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Fade: the input fades up out of `color_in` (black by default) —
|
||||
/// `progress_in = 0` is the fade color, `1` the picture untouched. The
|
||||
/// layer form has one picture (its `blend_in` is the optional pre-seam
|
||||
/// reference), so the two-sided dip of the timeline block's fade has no
|
||||
/// pair to dip through here.
|
||||
const SHADER_FADE: &str = r#"uniform sampler2D tex_in;
|
||||
uniform bool tex_in_enabled;
|
||||
uniform vec4 color_in;
|
||||
uniform float progress_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main(void) {
|
||||
if (!tex_in_enabled) {
|
||||
frag_color = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
vec4 tex_col = texture(tex_in, ove_texcoord);
|
||||
|
||||
frag_color = mix(color_in, tex_col, progress_in);
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Wipe: a soft-edged boundary sweeps across the frame, the incoming
|
||||
/// picture following behind it. The direction combo picks the axis and
|
||||
/// the sign with two steps and two mixes — no branch on the index, so
|
||||
/// all four directions are one path.
|
||||
const SHADER_WIPE: &str = r#"uniform sampler2D tex_in;
|
||||
uniform sampler2D blend_in;
|
||||
uniform bool tex_in_enabled;
|
||||
uniform bool blend_in_enabled;
|
||||
uniform float progress_in;
|
||||
uniform float direction_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main(void) {
|
||||
if (!tex_in_enabled && !blend_in_enabled) {
|
||||
frag_color = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tex_in_enabled) {
|
||||
frag_color = texture(blend_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!blend_in_enabled) {
|
||||
frag_color = texture(tex_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
vec4 tex_col = texture(tex_in, ove_texcoord);
|
||||
vec4 blend_col = texture(blend_in, ove_texcoord);
|
||||
|
||||
// Direction 0..3: 0/1 are horizontal, 2/3 vertical (the axis), and
|
||||
// the odd entries sweep the other way (the sign).
|
||||
float d = clamp(direction_in, 0.0, 3.0);
|
||||
float horiz = 1.0 - step(1.5, d);
|
||||
float neg = step(1.0, mod(d, 2.0));
|
||||
float sweep = mix(ove_texcoord.y, ove_texcoord.x, horiz);
|
||||
float boundary = mix(sweep, 1.0 - sweep, neg);
|
||||
|
||||
float soft = 0.02;
|
||||
float mask = smoothstep(progress_in - soft, progress_in + soft, boundary);
|
||||
|
||||
frag_color = mix(blend_col, tex_col, mask);
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Slide: the incoming picture pushes the outgoing one off the frame,
|
||||
/// both travelling the direction the combo names. The offsets mirror
|
||||
/// each other (`-e * progress` against `+e * (1 - progress)`), so the
|
||||
/// pair stays adjacent and neither picture is stretched.
|
||||
const SHADER_SLIDE: &str = r#"uniform sampler2D tex_in;
|
||||
uniform sampler2D blend_in;
|
||||
uniform bool tex_in_enabled;
|
||||
uniform bool blend_in_enabled;
|
||||
uniform float progress_in;
|
||||
uniform float direction_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main(void) {
|
||||
if (!tex_in_enabled && !blend_in_enabled) {
|
||||
frag_color = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tex_in_enabled) {
|
||||
frag_color = texture(blend_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!blend_in_enabled) {
|
||||
frag_color = texture(tex_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
// Direction 0..3: 0/1 are horizontal, 2/3 vertical (the axis), and
|
||||
// the odd entries travel the other way (the sign).
|
||||
float d = clamp(direction_in, 0.0, 3.0);
|
||||
float horiz = 1.0 - step(1.5, d);
|
||||
float neg = step(1.0, mod(d, 2.0));
|
||||
vec2 e = vec2(horiz, 1.0 - horiz) * (1.0 - 2.0 * neg);
|
||||
|
||||
float sweep = mix(ove_texcoord.y, ove_texcoord.x, horiz);
|
||||
float boundary = mix(sweep, 1.0 - sweep, neg);
|
||||
|
||||
vec2 from_uv = ove_texcoord - e * progress_in;
|
||||
vec2 to_uv = ove_texcoord + e * (1.0 - progress_in);
|
||||
|
||||
vec4 tex_col = texture(tex_in, from_uv);
|
||||
vec4 blend_col = texture(blend_in, to_uv);
|
||||
|
||||
if (boundary < progress_in) {
|
||||
frag_color = blend_col;
|
||||
} else {
|
||||
frag_color = tex_col;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Layer transition node. Unit-like: the style rides in `type_in`, so
|
||||
/// there is no per-instance state.
|
||||
pub struct TransitionFxNode;
|
||||
|
||||
impl TransitionFxNode {
|
||||
/// The fragment source for a shader id; an unknown id falls back to
|
||||
/// cross dissolve.
|
||||
fn shader_frag(shader_id: &str) -> &'static str {
|
||||
match shader_id {
|
||||
"fade" => SHADER_FADE,
|
||||
"wipe" => SHADER_WIPE,
|
||||
"slide" => SHADER_SLIDE,
|
||||
_ => SHADER_CROSSDISSOLVE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for TransitionFxNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Transition FX"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.transitionfx"
|
||||
}
|
||||
|
||||
/// Categories. Filed under effect: the `Category` enum has no
|
||||
/// transition group, and this is a video effect on a chain (the
|
||||
/// timeline transition block carries `Category::Timeline` instead).
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Effect]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Blend a picture with a second one across an adjustment layer's span."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "From", `blend_in` -> "To",
|
||||
/// `progress_in` -> "Progress", `color_in` -> "Color",
|
||||
/// `direction_in` -> "Direction", `type_in` -> "Type".
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "From",
|
||||
BLEND_INPUT => "To",
|
||||
PROGRESS_INPUT => "Progress",
|
||||
COLOR_INPUT => "Color",
|
||||
DIRECTION_INPUT => "Direction",
|
||||
TYPE_INPUT => "Type",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: if only one texture is present, push it as-is
|
||||
/// (a transition against nothing is the input itself); if both are
|
||||
/// present, push one shader job over the whole input row; if
|
||||
/// neither, push nothing.
|
||||
///
|
||||
/// The shader id the `type_in` combo selects is written into the job
|
||||
/// row. `progress_in` is deliberately **not** written when the user
|
||||
/// never set it: the renderer pre-fills the row from the adjustment
|
||||
/// layer's own position (`hooks.layer_progress`) and only a missing
|
||||
/// param lets that through. "Set" means the row value differs from
|
||||
/// the `0.0` default or the input is keyframed — the standard-value
|
||||
/// map cannot answer it, because the project serializer writes a
|
||||
/// `<standard>` element for every input, so a reloaded node always
|
||||
/// has one.
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
use crate::value::{NodeValue, ValueType};
|
||||
|
||||
let tex = inputs.get(TEXTURE_INPUT);
|
||||
let blend = inputs.get(BLEND_INPUT);
|
||||
|
||||
match (tex, blend) {
|
||||
(Some(NodeValue::Texture(_)), Some(NodeValue::Texture(_))) => {
|
||||
let ty = match inputs.get(TYPE_INPUT) {
|
||||
Some(v) => v.to_double() as i64,
|
||||
None => core.value_at_time(TYPE_INPUT, -1, time).to_double() as i64,
|
||||
};
|
||||
|
||||
let mut params = inputs.clone();
|
||||
params.insert(TYPE_INPUT.to_string(), NodeValue::Combo(ty));
|
||||
|
||||
let progress_set = inputs
|
||||
.get(PROGRESS_INPUT)
|
||||
.map(|v| v.to_double() != 0.0)
|
||||
.unwrap_or(false)
|
||||
|| core.is_input_keyframing(PROGRESS_INPUT, -1);
|
||||
if !progress_set {
|
||||
params.remove(PROGRESS_INPUT);
|
||||
}
|
||||
|
||||
table.push(
|
||||
ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: shader_id_for(ty).to_string(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
(Some(t @ NodeValue::Texture(_)), _) => {
|
||||
table.push(ValueType::Texture, t.clone(), None);
|
||||
}
|
||||
(_, Some(b @ NodeValue::Texture(_))) => {
|
||||
table.push(ValueType::Texture, b.clone(), None);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shader code request: the fragment source for the requested style
|
||||
/// id (see [`SHADER_IDS`]).
|
||||
fn shader_code(&self, request: &str) -> Option<String> {
|
||||
Some(Self::shader_frag(request).to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(TransitionFxNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds the two textures, `progress_in`, `color_in`,
|
||||
/// `direction_in` and `type_in` with the defaults and properties
|
||||
/// documented on the constants, and sets the video-effect flag with
|
||||
/// `tex_in` as the effect input (the chain hangs off it; `blend_in`
|
||||
/// binds by name alongside).
|
||||
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
|
||||
let mut core = NodeCore::new();
|
||||
|
||||
let mut tex = crate::input::Input::new(
|
||||
TEXTURE_INPUT,
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::None,
|
||||
);
|
||||
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
|
||||
core.add_input(tex);
|
||||
|
||||
let mut blend = crate::input::Input::new(
|
||||
BLEND_INPUT,
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::None,
|
||||
);
|
||||
blend.flags |= crate::input::flags::NOT_KEYFRAMABLE;
|
||||
core.add_input(blend);
|
||||
|
||||
let mut progress = crate::input::Input::new(
|
||||
PROGRESS_INPUT,
|
||||
crate::value::ValueType::Float,
|
||||
crate::value::NodeValue::Float(0.0),
|
||||
);
|
||||
progress.properties = vec![
|
||||
("min".to_string(), crate::value::NodeValue::Float(0.0)),
|
||||
("max".to_string(), crate::value::NodeValue::Float(1.0)),
|
||||
(
|
||||
"view".to_string(),
|
||||
crate::value::NodeValue::Text("percentage".into()),
|
||||
),
|
||||
];
|
||||
core.add_input(progress);
|
||||
|
||||
let color = crate::input::Input::new(
|
||||
COLOR_INPUT,
|
||||
crate::value::ValueType::Color,
|
||||
crate::value::NodeValue::Color([0.0, 0.0, 0.0, 1.0]),
|
||||
);
|
||||
core.add_input(color);
|
||||
|
||||
let mut direction = crate::input::Input::new(
|
||||
DIRECTION_INPUT,
|
||||
crate::value::ValueType::Combo,
|
||||
crate::value::NodeValue::Combo(0),
|
||||
);
|
||||
direction.flags |= crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
|
||||
direction.properties = vec![(
|
||||
"combobox_strings".to_string(),
|
||||
crate::value::NodeValue::Binary(DIRECTION_NAMES.join(",").into_bytes()),
|
||||
)];
|
||||
core.add_input(direction);
|
||||
|
||||
let mut ty = crate::input::Input::new(
|
||||
TYPE_INPUT,
|
||||
crate::value::ValueType::Combo,
|
||||
crate::value::NodeValue::Combo(0),
|
||||
);
|
||||
ty.flags |= crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
|
||||
ty.properties = vec![(
|
||||
"combobox_strings".to_string(),
|
||||
crate::value::NodeValue::Binary(TYPE_NAMES.join(",").into_bytes()),
|
||||
)];
|
||||
core.add_input(ty);
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
core.effect_input = TEXTURE_INPUT.to_string();
|
||||
|
||||
(core, Box::new(TransitionFxNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::keyframe::{Interpolation, Keyframe};
|
||||
use crate::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
|
||||
use oak_core::Rational;
|
||||
|
||||
fn tex() -> NodeValue {
|
||||
NodeValue::Texture(crate::handle::CHandle::null())
|
||||
}
|
||||
|
||||
fn run(core: &NodeCore, inputs: &NodeValueRow) -> ShaderJobPayload {
|
||||
let behavior = TransitionFxNode;
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(core, inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("shader job expected");
|
||||
};
|
||||
unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed")
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The row the graph traverser builds for an unconnected node with
|
||||
/// both textures: every input is present, `progress_in` carrying the
|
||||
/// input default.
|
||||
fn both() -> NodeValueRow {
|
||||
NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(BLEND_INPUT.to_string(), tex()),
|
||||
(PROGRESS_INPUT.to_string(), NodeValue::Float(0.0)),
|
||||
(COLOR_INPUT.to_string(), NodeValue::Color([0.0, 0.0, 0.0, 1.0])),
|
||||
(DIRECTION_INPUT.to_string(), NodeValue::Combo(0)),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_names() {
|
||||
let n = TransitionFxNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "From");
|
||||
assert_eq!(n.input_name(BLEND_INPUT), "To");
|
||||
assert_eq!(n.input_name(PROGRESS_INPUT), "Progress");
|
||||
assert_eq!(n.input_name(COLOR_INPUT), "Color");
|
||||
assert_eq!(n.input_name(DIRECTION_INPUT), "Direction");
|
||||
assert_eq!(n.input_name(TYPE_INPUT), "Type");
|
||||
assert_eq!(n.input_name("other"), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs_and_flags() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(
|
||||
behavior.type_id(),
|
||||
"org.olivevideoeditor.Olive.transitionfx"
|
||||
);
|
||||
assert_eq!(behavior.name(), "Transition FX");
|
||||
assert_eq!(behavior.categories(), &[Category::Effect]);
|
||||
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
|
||||
assert_eq!(core.effect_input, TEXTURE_INPUT, "the chain hangs off tex_in");
|
||||
assert_eq!(
|
||||
core.get_input(TEXTURE_INPUT).map(|i| i.value_type),
|
||||
Some(ValueType::Texture)
|
||||
);
|
||||
|
||||
for id in [TEXTURE_INPUT, BLEND_INPUT] {
|
||||
let input = core.get_input(id).expect("texture input");
|
||||
assert_ne!(input.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
|
||||
}
|
||||
|
||||
let progress = core.get_input(PROGRESS_INPUT).expect("progress input");
|
||||
assert_eq!(progress.default, NodeValue::Float(0.0));
|
||||
assert!(progress
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "min" && *v == NodeValue::Float(0.0)));
|
||||
assert!(progress
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "max" && *v == NodeValue::Float(1.0)));
|
||||
|
||||
let color = core.get_input(COLOR_INPUT).expect("color input");
|
||||
assert_eq!(color.default, NodeValue::Color([0.0, 0.0, 0.0, 1.0]));
|
||||
|
||||
let direction = core.get_input(DIRECTION_INPUT).expect("direction input");
|
||||
assert_eq!(direction.default, NodeValue::Combo(0));
|
||||
assert_ne!(direction.flags & crate::input::flags::NOT_CONNECTABLE, 0);
|
||||
assert_eq!(
|
||||
direction.properties.first(),
|
||||
Some(&(
|
||||
"combobox_strings".to_string(),
|
||||
NodeValue::Binary(DIRECTION_NAMES.join(",").into_bytes())
|
||||
))
|
||||
);
|
||||
|
||||
let ty = core.get_input(TYPE_INPUT).expect("type input");
|
||||
assert_eq!(ty.default, NodeValue::Combo(0));
|
||||
assert_ne!(ty.flags & crate::input::flags::NOT_CONNECTABLE, 0);
|
||||
assert_eq!(
|
||||
ty.properties.first(),
|
||||
Some(&(
|
||||
"combobox_strings".to_string(),
|
||||
NodeValue::Binary("Cross Dissolve,Fade,Wipe,Slide".as_bytes().to_vec())
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_ids_cover_every_type_name() {
|
||||
assert_eq!(TYPE_NAMES.len(), SHADER_IDS.len());
|
||||
assert_eq!(shader_id_for(0), "crossdissolve");
|
||||
assert_eq!(shader_id_for(1), "fade");
|
||||
assert_eq!(shader_id_for(2), "wipe");
|
||||
assert_eq!(shader_id_for(3), "slide");
|
||||
assert_eq!(shader_id_for(-1), "crossdissolve");
|
||||
assert_eq!(shader_id_for(7), "slide");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direction_names_cover_every_index() {
|
||||
assert_eq!(DIRECTION_NAMES.len(), 4);
|
||||
for (i, name) in DIRECTION_NAMES.iter().enumerate() {
|
||||
assert!(!name.is_empty(), "{i} has a label");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_no_texture_pushes_nothing() {
|
||||
let (core, behavior) = create();
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &NodeValueRow::default(), Rational::new(0, 1), &mut table);
|
||||
assert!(table.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_tex_only_pushes_tex() {
|
||||
let (core, behavior) = create();
|
||||
let input = tex();
|
||||
let inputs = NodeValueRow::from([(TEXTURE_INPUT.to_string(), input.clone())]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert_eq!(table.get(ValueType::Texture), Some(&input));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_blend_only_pushes_blend() {
|
||||
let (core, behavior) = create();
|
||||
let blend = tex();
|
||||
let inputs = NodeValueRow::from([(BLEND_INPUT.to_string(), blend.clone())]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert_eq!(table.get(ValueType::Texture), Some(&blend));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_both_pushes_job_payload_with_both_textures() {
|
||||
let (core, _) = create();
|
||||
let payload = run(&core, &both());
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.transitionfx");
|
||||
assert_eq!(payload.shader_id, "crossdissolve", "combo default is index 0");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
assert_eq!(payload.effect_input, TEXTURE_INPUT);
|
||||
assert!(payload.params.contains_key(TEXTURE_INPUT));
|
||||
assert!(payload.params.contains_key(BLEND_INPUT));
|
||||
assert!(
|
||||
!payload.params.contains_key(PROGRESS_INPUT),
|
||||
"the default progress stays out of the row so the layer can fill it"
|
||||
);
|
||||
assert_eq!(
|
||||
payload.params.get(COLOR_INPUT),
|
||||
Some(&NodeValue::Color([0.0, 0.0, 0.0, 1.0])),
|
||||
"the style parameters ride along"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_takes_type_from_row_and_core() {
|
||||
let (mut core, _) = create();
|
||||
let mut inputs = both();
|
||||
inputs.insert(TYPE_INPUT.to_string(), NodeValue::Combo(2));
|
||||
assert_eq!(run(&core, &inputs).shader_id, "wipe");
|
||||
|
||||
core.set_standard_value(TYPE_INPUT, -1, NodeValue::Combo(3));
|
||||
assert_eq!(run(&core, &both()).shader_id, "slide");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_explicit_progress_stays_in_the_job() {
|
||||
let (core, _) = create();
|
||||
let mut inputs = both();
|
||||
inputs.insert(PROGRESS_INPUT.to_string(), NodeValue::Float(0.75));
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(
|
||||
payload.params.get(PROGRESS_INPUT),
|
||||
Some(&NodeValue::Float(0.75)),
|
||||
"a user-set progress overrides the layer sweep"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_keyframed_progress_stays_in_the_job() {
|
||||
let (mut core, _) = create();
|
||||
core.keyframe_track_mut(PROGRESS_INPUT, -1)
|
||||
.set_key(Keyframe {
|
||||
time: Rational::new(0, 1),
|
||||
value: NodeValue::Float(0.0),
|
||||
interpolation: Interpolation::Linear,
|
||||
bezier_in: (0.0, 0.0),
|
||||
bezier_out: (0.0, 0.0),
|
||||
});
|
||||
let mut inputs = both();
|
||||
inputs.insert(
|
||||
PROGRESS_INPUT.to_string(),
|
||||
core.value_at_time(PROGRESS_INPUT, -1, Rational::new(0, 1)),
|
||||
);
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(
|
||||
payload.params.get(PROGRESS_INPUT),
|
||||
Some(&NodeValue::Float(0.0)),
|
||||
"a keyframed progress is authored, even when its value is 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_selects_variant_without_switch() {
|
||||
let n = TransitionFxNode;
|
||||
let sources: Vec<String> = SHADER_IDS
|
||||
.iter()
|
||||
.map(|id| n.shader_code(id).expect("shader source"))
|
||||
.collect();
|
||||
for (i, glsl) in sources.iter().enumerate() {
|
||||
assert!(glsl.contains("uniform sampler2D tex_in;"), "{i} binds tex_in");
|
||||
assert!(glsl.contains("uniform bool tex_in_enabled;"), "{i}");
|
||||
assert!(glsl.contains("uniform float progress_in;"), "{i}");
|
||||
assert!(!glsl.contains("switch"));
|
||||
}
|
||||
for i in [0usize, 2, 3] {
|
||||
assert!(
|
||||
sources[i].contains("uniform sampler2D blend_in;"),
|
||||
"{i} binds the second picture"
|
||||
);
|
||||
assert!(sources[i].contains("uniform bool blend_in_enabled;"), "{i}");
|
||||
}
|
||||
assert!(sources[1].contains("uniform vec4 color_in;"), "fade fades");
|
||||
for i in [2usize, 3] {
|
||||
assert!(
|
||||
sources[i].contains("uniform float direction_in;"),
|
||||
"{i} sweeps"
|
||||
);
|
||||
}
|
||||
for i in 0..sources.len() {
|
||||
for j in (i + 1)..sources.len() {
|
||||
assert_ne!(sources[i], sources[j], "types {i} and {j} differ");
|
||||
}
|
||||
}
|
||||
assert!(sources[0].contains("mix(tex_col, blend_col, progress_in)"));
|
||||
assert!(sources[1].contains("mix(color_in, tex_col, progress_in)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_unknown_id_falls_back_to_cross_dissolve() {
|
||||
let n = TransitionFxNode;
|
||||
assert_eq!(
|
||||
n.shader_code("nonsense"),
|
||||
n.shader_code(SHADER_IDS[0]),
|
||||
"an unknown style id renders as cross dissolve"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones_behavior() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Transition FX");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.transitionfx",
|
||||
name: "Transition FX",
|
||||
categories: &[Category::Effect],
|
||||
create,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Transition node: blends the two textures a timeline transition block
|
||||
//! sits between, driven by a `progress_in` factor.
|
||||
//!
|
||||
//! One behavior carries all four transition styles
|
||||
//! ([`TYPE_NAMES`]); the `type_in` combo picks the fragment shader
|
||||
//! through its shader id ([`SHADER_IDS`]) — the same
|
||||
//! `node->get_shader_code(shader_id)` dispatch the math nodes use. The
|
||||
//! two texture inputs are named `tex_in` ("from", the clip before the
|
||||
//! cut) and `blend_in` ("to", the clip after it), matching the timeline
|
||||
//! transition block's `out_block_in`/`in_block_in` wiring; `progress_in`
|
||||
//! is the block's position across its own span, `0.0` at the cut's
|
||||
//! outgoing side and `1.0` at its incoming side.
|
||||
//!
|
||||
//! Like [`crate::nodes::dissolve`], the node never sets a
|
||||
//! `core.effect_input`: the both-present case boxes one
|
||||
//! [`ShaderJobPayload`] whose param row carries both textures, and the
|
||||
//! renderer binds `tex_in`/`blend_in` by name.
|
||||
|
||||
use crate::factory::NodeMeta;
|
||||
use crate::jobs::ShaderJobPayload;
|
||||
use crate::node::{Category, NodeBehavior, NodeCore};
|
||||
|
||||
/// Outgoing ("from") texture input id. Type: texture; flags:
|
||||
/// not-keyframable.
|
||||
pub const TEXTURE_INPUT: &str = "tex_in";
|
||||
|
||||
/// Incoming ("to") texture input id. Type: texture; flags:
|
||||
/// not-keyframable.
|
||||
pub const BLEND_INPUT: &str = "blend_in";
|
||||
|
||||
/// Transition progress input id. Type: float; default `0.0`;
|
||||
/// properties: `min = 0.0`, `max = 1.0`, `view = percentage`. `0.0`
|
||||
/// yields `tex_in`, `1.0` yields `blend_in`.
|
||||
pub const PROGRESS_INPUT: &str = "progress_in";
|
||||
|
||||
/// Transition style input id (the combo the block and the inspector
|
||||
/// both edit). Type: combo; flags: not-connectable, not-keyframable;
|
||||
/// properties: `combobox_strings` = [`TYPE_NAMES`].
|
||||
pub const TYPE_INPUT: &str = "type_in";
|
||||
|
||||
/// Localized style names, in combo-index order.
|
||||
pub const TYPE_NAMES: [&str; 4] = ["Cross Dissolve", "Fade", "Wipe", "Slide"];
|
||||
|
||||
/// Shader ids for [`TYPE_NAMES`], by combo index. These are the strings
|
||||
/// the timeline transition block stores so the composite driver can ask
|
||||
/// the factory for the matching fragment source without instantiating
|
||||
/// this node in the project graph.
|
||||
pub const SHADER_IDS: [&str; 4] = ["crossdissolve", "fade", "wipe", "slide"];
|
||||
|
||||
/// The shader id for a combo index, clamping out-of-range values onto
|
||||
/// cross dissolve (index `0`), the C++ default transition.
|
||||
pub fn shader_id_for(index: i64) -> &'static str {
|
||||
SHADER_IDS[index.clamp(0, SHADER_IDS.len() as i64 - 1) as usize]
|
||||
}
|
||||
|
||||
/// Cross dissolve: a straight mix, `50/50` at the midpoint.
|
||||
const SHADER_CROSSDISSOLVE: &str = r#"uniform sampler2D tex_in;
|
||||
uniform sampler2D blend_in;
|
||||
uniform bool tex_in_enabled;
|
||||
uniform bool blend_in_enabled;
|
||||
uniform float progress_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main(void) {
|
||||
if (!tex_in_enabled && !blend_in_enabled) {
|
||||
frag_color = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tex_in_enabled) {
|
||||
frag_color = texture(blend_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!blend_in_enabled) {
|
||||
frag_color = texture(tex_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
vec4 tex_col = texture(tex_in, ove_texcoord);
|
||||
vec4 blend_col = texture(blend_in, ove_texcoord);
|
||||
|
||||
frag_color = mix(tex_col, blend_col, progress_in);
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Fade: a dip through the fade color (black), outgoing image down over
|
||||
/// the first half, incoming image up over the second — the classic
|
||||
/// "fade to color" cut. The other end of the dip (white) is not exposed
|
||||
/// yet: W5 exposes no fade-color parameter.
|
||||
const SHADER_FADE: &str = r#"uniform sampler2D tex_in;
|
||||
uniform sampler2D blend_in;
|
||||
uniform bool tex_in_enabled;
|
||||
uniform bool blend_in_enabled;
|
||||
uniform float progress_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main(void) {
|
||||
if (!tex_in_enabled && !blend_in_enabled) {
|
||||
frag_color = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tex_in_enabled) {
|
||||
frag_color = texture(blend_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!blend_in_enabled) {
|
||||
frag_color = texture(tex_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
vec4 tex_col = texture(tex_in, ove_texcoord);
|
||||
vec4 blend_col = texture(blend_in, ove_texcoord);
|
||||
vec4 fade_col = vec4(0.0, 0.0, 0.0, 1.0);
|
||||
|
||||
if (progress_in < 0.5) {
|
||||
frag_color = mix(tex_col, fade_col, progress_in * 2.0);
|
||||
} else {
|
||||
frag_color = mix(fade_col, blend_col, (progress_in - 0.5) * 2.0);
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Wipe: a soft-edged boundary sweeps left to right, the incoming image
|
||||
/// following behind it.
|
||||
const SHADER_WIPE: &str = r#"uniform sampler2D tex_in;
|
||||
uniform sampler2D blend_in;
|
||||
uniform bool tex_in_enabled;
|
||||
uniform bool blend_in_enabled;
|
||||
uniform float progress_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main(void) {
|
||||
if (!tex_in_enabled && !blend_in_enabled) {
|
||||
frag_color = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tex_in_enabled) {
|
||||
frag_color = texture(blend_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!blend_in_enabled) {
|
||||
frag_color = texture(tex_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
vec4 tex_col = texture(tex_in, ove_texcoord);
|
||||
vec4 blend_col = texture(blend_in, ove_texcoord);
|
||||
|
||||
float soft = 0.02;
|
||||
float boundary = progress_in;
|
||||
float mask = smoothstep(boundary - soft, boundary + soft, ove_texcoord.x);
|
||||
|
||||
frag_color = mix(blend_col, tex_col, mask);
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Slide: the outgoing image is pushed off to the left while the
|
||||
/// incoming image slides in from the right (the C++ "push" direction).
|
||||
const SHADER_SLIDE: &str = r#"uniform sampler2D tex_in;
|
||||
uniform sampler2D blend_in;
|
||||
uniform bool tex_in_enabled;
|
||||
uniform bool blend_in_enabled;
|
||||
uniform float progress_in;
|
||||
|
||||
in vec2 ove_texcoord;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main(void) {
|
||||
if (!tex_in_enabled && !blend_in_enabled) {
|
||||
frag_color = vec4(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tex_in_enabled) {
|
||||
frag_color = texture(blend_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!blend_in_enabled) {
|
||||
frag_color = texture(tex_in, ove_texcoord);
|
||||
return;
|
||||
}
|
||||
|
||||
vec2 from_uv = ove_texcoord + vec2(progress_in, 0.0);
|
||||
vec2 to_uv = ove_texcoord - vec2(1.0 - progress_in, 0.0);
|
||||
|
||||
vec4 tex_col = texture(tex_in, from_uv);
|
||||
vec4 blend_col = texture(blend_in, to_uv);
|
||||
|
||||
if (ove_texcoord.x < 1.0 - progress_in) {
|
||||
frag_color = tex_col;
|
||||
} else {
|
||||
frag_color = blend_col;
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
/// Transition node. Unit-like: the style rides in `type_in`, so there is
|
||||
/// no per-instance state.
|
||||
pub struct TransitionNode;
|
||||
|
||||
impl TransitionNode {
|
||||
/// The fragment source for a shader id; an unknown id falls back to
|
||||
/// cross dissolve (the only style the timeline creates by default).
|
||||
fn shader_frag(shader_id: &str) -> &'static str {
|
||||
match shader_id {
|
||||
"fade" => SHADER_FADE,
|
||||
"wipe" => SHADER_WIPE,
|
||||
"slide" => SHADER_SLIDE,
|
||||
_ => SHADER_CROSSDISSOLVE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NodeBehavior for TransitionNode {
|
||||
/// Human-readable name.
|
||||
fn name(&self) -> &str {
|
||||
"Transition"
|
||||
}
|
||||
|
||||
/// Stable type id.
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.transition"
|
||||
}
|
||||
|
||||
/// Categories. Filed under timeline (the `Category` enum has no
|
||||
/// transition group; the transition *block* sits in the same one).
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[Category::Timeline]
|
||||
}
|
||||
|
||||
/// Description.
|
||||
fn description(&self) -> &str {
|
||||
"Blend the two sides of a cut with a transition style."
|
||||
}
|
||||
|
||||
/// Localized input names: `tex_in` -> "From", `blend_in` -> "To",
|
||||
/// `progress_in` -> "Progress", `type_in` -> "Type" (the ids the
|
||||
/// transition block's `out_block_in`/`in_block_in` display names
|
||||
/// mirror).
|
||||
fn input_name<'a>(&self, id: &'a str) -> &'a str {
|
||||
match id {
|
||||
TEXTURE_INPUT => "From",
|
||||
BLEND_INPUT => "To",
|
||||
PROGRESS_INPUT => "Progress",
|
||||
TYPE_INPUT => "Type",
|
||||
_ => id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate outputs: if only one texture is present, push it as-is
|
||||
/// (a transition against nothing is the input itself); if both are
|
||||
/// present, push one shader job over the whole input row; if
|
||||
/// neither, push nothing.
|
||||
///
|
||||
/// The resolved progress and the shader id the `type_in` combo
|
||||
/// selects are written into the job row, so the `progress_in`
|
||||
/// uniform is always present (the composite driver relies on that,
|
||||
/// and `run_effect`'s row is otherwise only as complete as the
|
||||
/// caller's).
|
||||
fn value(
|
||||
&self,
|
||||
core: &NodeCore,
|
||||
inputs: &crate::value::NodeValueRow,
|
||||
time: oak_core::Rational,
|
||||
table: &mut crate::value::NodeValueTable,
|
||||
) {
|
||||
use crate::value::{NodeValue, ValueType};
|
||||
|
||||
let tex = inputs.get(TEXTURE_INPUT);
|
||||
let blend = inputs.get(BLEND_INPUT);
|
||||
|
||||
match (tex, blend) {
|
||||
(Some(NodeValue::Texture(_)), Some(NodeValue::Texture(_))) => {
|
||||
let progress = match inputs.get(PROGRESS_INPUT) {
|
||||
Some(v) => v.to_double(),
|
||||
None => core.value_at_time(PROGRESS_INPUT, -1, time).to_double(),
|
||||
};
|
||||
let ty = match inputs.get(TYPE_INPUT) {
|
||||
Some(v) => v.to_double() as i64,
|
||||
None => core.value_at_time(TYPE_INPUT, -1, time).to_double() as i64,
|
||||
};
|
||||
|
||||
let mut params = inputs.clone();
|
||||
params.insert(PROGRESS_INPUT.to_string(), NodeValue::Float(progress));
|
||||
params.insert(TYPE_INPUT.to_string(), NodeValue::Combo(ty));
|
||||
|
||||
table.push(
|
||||
ValueType::Texture,
|
||||
NodeValue::Texture(crate::handle::make_owned(ShaderJobPayload {
|
||||
node_id: crate::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: self.type_id().to_string(),
|
||||
shader_id: shader_id_for(ty).to_string(),
|
||||
effect_input: core.effect_input.clone(),
|
||||
params,
|
||||
iterative_input: String::new(),
|
||||
})),
|
||||
None,
|
||||
);
|
||||
}
|
||||
(Some(t @ NodeValue::Texture(_)), _) => {
|
||||
table.push(ValueType::Texture, t.clone(), None);
|
||||
}
|
||||
(_, Some(b @ NodeValue::Texture(_))) => {
|
||||
table.push(ValueType::Texture, b.clone(), None);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shader code request: the fragment source for the requested style
|
||||
/// id (see [`SHADER_IDS`]).
|
||||
fn shader_code(&self, request: &str) -> Option<String> {
|
||||
Some(Self::shader_frag(request).to_string())
|
||||
}
|
||||
|
||||
/// Deep copy.
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(TransitionNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor: adds `tex_in`, `blend_in`, `progress_in` and `type_in`
|
||||
/// with the defaults and properties documented on the constants and sets
|
||||
/// the video-effect flag (no effect input: both textures bind by name).
|
||||
pub fn create() -> (NodeCore, Box<dyn NodeBehavior>) {
|
||||
let mut core = NodeCore::new();
|
||||
|
||||
let mut tex = crate::input::Input::new(
|
||||
TEXTURE_INPUT,
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::None,
|
||||
);
|
||||
tex.flags |= crate::input::flags::NOT_KEYFRAMABLE;
|
||||
core.add_input(tex);
|
||||
|
||||
let mut blend = crate::input::Input::new(
|
||||
BLEND_INPUT,
|
||||
crate::value::ValueType::Texture,
|
||||
crate::value::NodeValue::None,
|
||||
);
|
||||
blend.flags |= crate::input::flags::NOT_KEYFRAMABLE;
|
||||
core.add_input(blend);
|
||||
|
||||
let mut progress = crate::input::Input::new(
|
||||
PROGRESS_INPUT,
|
||||
crate::value::ValueType::Float,
|
||||
crate::value::NodeValue::Float(0.0),
|
||||
);
|
||||
progress.properties = vec![
|
||||
("min".to_string(), crate::value::NodeValue::Float(0.0)),
|
||||
("max".to_string(), crate::value::NodeValue::Float(1.0)),
|
||||
(
|
||||
"view".to_string(),
|
||||
crate::value::NodeValue::Text("percentage".into()),
|
||||
),
|
||||
];
|
||||
core.add_input(progress);
|
||||
|
||||
let mut ty = crate::input::Input::new(
|
||||
TYPE_INPUT,
|
||||
crate::value::ValueType::Combo,
|
||||
crate::value::NodeValue::Combo(0),
|
||||
);
|
||||
ty.flags |= crate::input::flags::NOT_CONNECTABLE | crate::input::flags::NOT_KEYFRAMABLE;
|
||||
ty.properties = vec![(
|
||||
"combobox_strings".to_string(),
|
||||
crate::value::NodeValue::Binary(TYPE_NAMES.join(",").into_bytes()),
|
||||
)];
|
||||
core.add_input(ty);
|
||||
|
||||
core.flags |= crate::node::flags::VIDEO_EFFECT;
|
||||
|
||||
(core, Box::new(TransitionNode))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
|
||||
use oak_core::Rational;
|
||||
|
||||
fn tex() -> NodeValue {
|
||||
NodeValue::Texture(crate::handle::CHandle::null())
|
||||
}
|
||||
|
||||
fn run(core: &NodeCore, inputs: &NodeValueRow) -> ShaderJobPayload {
|
||||
let behavior = TransitionNode;
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(core, inputs, Rational::new(0, 1), &mut table);
|
||||
let Some(NodeValue::Texture(h)) = table.get(ValueType::Texture) else {
|
||||
panic!("shader job expected");
|
||||
};
|
||||
unsafe { crate::handle::get_checked::<ShaderJobPayload>(h) }
|
||||
.expect("shader job payload boxed")
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn both() -> NodeValueRow {
|
||||
NodeValueRow::from([
|
||||
(TEXTURE_INPUT.to_string(), tex()),
|
||||
(BLEND_INPUT.to_string(), tex()),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_names() {
|
||||
let n = TransitionNode;
|
||||
assert_eq!(n.input_name(TEXTURE_INPUT), "From");
|
||||
assert_eq!(n.input_name(BLEND_INPUT), "To");
|
||||
assert_eq!(n.input_name(PROGRESS_INPUT), "Progress");
|
||||
assert_eq!(n.input_name(TYPE_INPUT), "Type");
|
||||
assert_eq!(n.input_name("other"), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_wires_inputs_and_flags() {
|
||||
let (core, behavior) = create();
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.transition");
|
||||
assert_eq!(behavior.name(), "Transition");
|
||||
assert_eq!(behavior.categories(), &[Category::Timeline]);
|
||||
assert_ne!(core.flags & crate::node::flags::VIDEO_EFFECT, 0);
|
||||
assert_eq!(core.effect_input, "");
|
||||
|
||||
for id in [TEXTURE_INPUT, BLEND_INPUT] {
|
||||
let input = core.get_input(id).expect("texture input");
|
||||
assert_ne!(input.flags & crate::input::flags::NOT_KEYFRAMABLE, 0);
|
||||
}
|
||||
|
||||
let progress = core.get_input(PROGRESS_INPUT).expect("progress input");
|
||||
assert_eq!(progress.default, NodeValue::Float(0.0));
|
||||
assert!(progress
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "min" && *v == NodeValue::Float(0.0)));
|
||||
assert!(progress
|
||||
.properties
|
||||
.iter()
|
||||
.any(|(k, v)| k == "max" && *v == NodeValue::Float(1.0)));
|
||||
|
||||
let ty = core.get_input(TYPE_INPUT).expect("type input");
|
||||
assert_eq!(ty.default, NodeValue::Combo(0));
|
||||
assert_ne!(ty.flags & crate::input::flags::NOT_CONNECTABLE, 0);
|
||||
assert_eq!(
|
||||
ty.properties.first(),
|
||||
Some(&(
|
||||
"combobox_strings".to_string(),
|
||||
NodeValue::Binary("Cross Dissolve,Fade,Wipe,Slide".as_bytes().to_vec())
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_ids_cover_every_type_name() {
|
||||
assert_eq!(TYPE_NAMES.len(), SHADER_IDS.len());
|
||||
assert_eq!(shader_id_for(0), "crossdissolve");
|
||||
assert_eq!(shader_id_for(3), "slide");
|
||||
assert_eq!(shader_id_for(-1), "crossdissolve");
|
||||
assert_eq!(shader_id_for(7), "slide");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_no_texture_pushes_nothing() {
|
||||
let (core, behavior) = create();
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &NodeValueRow::default(), Rational::new(0, 1), &mut table);
|
||||
assert!(table.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_tex_only_pushes_tex() {
|
||||
let (core, behavior) = create();
|
||||
let input = tex();
|
||||
let inputs = NodeValueRow::from([(TEXTURE_INPUT.to_string(), input.clone())]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert_eq!(table.get(ValueType::Texture), Some(&input));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_blend_only_pushes_blend() {
|
||||
let (core, behavior) = create();
|
||||
let blend = tex();
|
||||
let inputs = NodeValueRow::from([(BLEND_INPUT.to_string(), blend.clone())]);
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
assert_eq!(table.get(ValueType::Texture), Some(&blend));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_both_pushes_job_payload_with_both_textures() {
|
||||
let (core, _) = create();
|
||||
let payload = run(&core, &both());
|
||||
assert_eq!(payload.type_id, "org.olivevideoeditor.Olive.transition");
|
||||
assert_eq!(payload.shader_id, "crossdissolve", "combo default is index 0");
|
||||
assert_eq!(payload.iterations, 1);
|
||||
assert_eq!(payload.iterative_input, "");
|
||||
assert!(payload.params.contains_key(TEXTURE_INPUT));
|
||||
assert!(payload.params.contains_key(BLEND_INPUT));
|
||||
assert_eq!(
|
||||
payload.params.get(PROGRESS_INPUT),
|
||||
Some(&NodeValue::Float(0.0)),
|
||||
"the progress factor rides in the job params"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_takes_progress_and_type_from_row() {
|
||||
let (core, _) = create();
|
||||
let mut inputs = both();
|
||||
inputs.insert(PROGRESS_INPUT.to_string(), NodeValue::Float(0.75));
|
||||
inputs.insert(TYPE_INPUT.to_string(), NodeValue::Combo(2));
|
||||
let payload = run(&core, &inputs);
|
||||
assert_eq!(payload.params.get(PROGRESS_INPUT), Some(&NodeValue::Float(0.75)));
|
||||
assert_eq!(payload.shader_id, "wipe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_takes_type_from_core() {
|
||||
let (mut core, _) = create();
|
||||
core.set_standard_value(TYPE_INPUT, -1, NodeValue::Combo(3));
|
||||
let payload = run(&core, &both());
|
||||
assert_eq!(payload.shader_id, "slide");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_selects_variant_without_switch() {
|
||||
let n = TransitionNode;
|
||||
let sources: Vec<String> = SHADER_IDS
|
||||
.iter()
|
||||
.map(|id| n.shader_code(id).expect("shader source"))
|
||||
.collect();
|
||||
for (i, glsl) in sources.iter().enumerate() {
|
||||
assert!(glsl.contains("uniform sampler2D tex_in;"), "{i} binds tex_in");
|
||||
assert!(
|
||||
glsl.contains("uniform sampler2D blend_in;"),
|
||||
"{i} binds blend_in"
|
||||
);
|
||||
assert!(glsl.contains("uniform bool tex_in_enabled;"));
|
||||
assert!(glsl.contains("uniform bool blend_in_enabled;"));
|
||||
assert!(glsl.contains("uniform float progress_in;"));
|
||||
assert!(!glsl.contains("switch"));
|
||||
}
|
||||
for i in 0..sources.len() {
|
||||
for j in (i + 1)..sources.len() {
|
||||
assert_ne!(sources[i], sources[j], "types {i} and {j} differ");
|
||||
}
|
||||
}
|
||||
assert!(sources[0].contains("mix(tex_col, blend_col, progress_in)"));
|
||||
assert!(sources[3].contains("1.0 - progress_in"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_code_unknown_id_falls_back_to_cross_dissolve() {
|
||||
let n = TransitionNode;
|
||||
assert_eq!(
|
||||
n.shader_code("nonsense"),
|
||||
n.shader_code(SHADER_IDS[0]),
|
||||
"an unknown style id renders as cross dissolve"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_clones_behavior() {
|
||||
let (core, behavior) = create();
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Transition");
|
||||
}
|
||||
}
|
||||
|
||||
/// Register this node type.
|
||||
pub fn register(meta: &mut Vec<NodeMeta>) {
|
||||
meta.push(NodeMeta {
|
||||
type_id: "org.olivevideoeditor.Olive.transition",
|
||||
name: "Transition",
|
||||
categories: &[Category::Timeline],
|
||||
create,
|
||||
});
|
||||
}
|
||||
@@ -737,6 +737,7 @@ fn create_timeline_type(
|
||||
"org.olivevideoeditor.Olive.transitionblock" => {
|
||||
Some(crate::block::transition_create())
|
||||
}
|
||||
"org.olivevideoeditor.Olive.adjustment" => Some(crate::block::adjustment_create()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -985,6 +986,12 @@ fn resolve_timeline_refs(graph: &mut Graph, id_map: &std::collections::HashMap<u
|
||||
{
|
||||
t.core.track = t.core.track.and_then(|r| resolve(&r));
|
||||
t.core.links = entry.core.links.clone();
|
||||
} else if let Some(a) = behavior
|
||||
.as_any_mut()
|
||||
.and_then(|a| a.downcast_mut::<crate::block::AdjustmentBlockBehavior>())
|
||||
{
|
||||
a.core.track = a.core.track.and_then(|r| resolve(&r));
|
||||
a.core.links = entry.core.links.clone();
|
||||
} else if let Some(f) = behavior
|
||||
.as_any_mut()
|
||||
.and_then(|a| a.downcast_mut::<crate::footage::FootageBehavior>())
|
||||
|
||||
@@ -56,6 +56,8 @@ const EXPECTED_ORDER: &[&str] = &[
|
||||
"org.olivevideoeditor.Olive.flip",
|
||||
"org.olivevideoeditor.Olive.noise",
|
||||
"org.olivevideoeditor.Olive.timeoffset",
|
||||
"org.olivevideoeditor.Olive.transition",
|
||||
"org.olivevideoeditor.Olive.transitionfx",
|
||||
"org.olivevideoeditor.Olive.cornerpin",
|
||||
"org.olivevideoeditor.Olive.displaytransform",
|
||||
"org.olivevideoeditor.Olive.ociogradingtransformlinear",
|
||||
@@ -71,6 +73,29 @@ const EXPECTED_ORDER: &[&str] = &[
|
||||
"org.olivevideoeditor.Olive.tile",
|
||||
"org.olivevideoeditor.Olive.swirl",
|
||||
"org.olivevideoeditor.Olive.ripple",
|
||||
"org.olivevideoeditor.Olive.colormatrix",
|
||||
"org.olivevideoeditor.Olive.dilate",
|
||||
"org.olivevideoeditor.Olive.edgedetect",
|
||||
"org.olivevideoeditor.Olive.erode",
|
||||
// The OpenFX-Misc cleanroom GPU ports, registered between the C++
|
||||
// menu order and multicam (see `nodes::register_all`).
|
||||
"org.olivevideoeditor.Olive.colorcorrect",
|
||||
"org.olivevideoeditor.Olive.gamma",
|
||||
"org.olivevideoeditor.Olive.saturation",
|
||||
"org.olivevideoeditor.Olive.invert",
|
||||
"org.olivevideoeditor.Olive.clamp",
|
||||
"org.olivevideoeditor.Olive.grade",
|
||||
"org.olivevideoeditor.Olive.dirblur",
|
||||
"org.olivevideoeditor.Olive.sharpen",
|
||||
"org.olivevideoeditor.Olive.dissolve",
|
||||
"org.olivevideoeditor.Olive.keymix",
|
||||
"org.olivevideoeditor.Olive.premult",
|
||||
"org.olivevideoeditor.Olive.unpremult",
|
||||
"org.olivevideoeditor.Olive.position",
|
||||
"org.olivevideoeditor.Olive.mirror",
|
||||
"org.olivevideoeditor.Olive.checkerboard",
|
||||
"org.olivevideoeditor.Olive.colorbars",
|
||||
"org.olivevideoeditor.Olive.ramp",
|
||||
"org.olivevideoeditor.Olive.multicam",
|
||||
];
|
||||
|
||||
|
||||
@@ -229,7 +229,10 @@ fn golden_project_with_footage_loads() {
|
||||
/// chain with keyframes, plus settings and a link.
|
||||
fn build_full_project() -> std::sync::Arc<std::sync::Mutex<oak_node::project::Project>> {
|
||||
use oak_core::{Rational, TimeRange};
|
||||
use oak_node::block::{clip_create, gap_create, ClipBlockBehavior, GapBlockBehavior};
|
||||
use oak_node::block::{
|
||||
adjustment_create, clip_create, gap_create, AdjustmentBlockBehavior, ClipBlockBehavior,
|
||||
GapBlockBehavior,
|
||||
};
|
||||
use oak_node::folder::FolderBehavior;
|
||||
use oak_node::footage::{FootageBehavior, StreamInfo};
|
||||
use oak_node::keyframe::{Interpolation, Keyframe};
|
||||
@@ -425,6 +428,18 @@ fn build_full_project() -> std::sync::Arc<std::sync::Mutex<oak_node::project::Pr
|
||||
p.graph.add_node(core, behavior)
|
||||
};
|
||||
|
||||
// A standalone adjustment layer at the end of the video track.
|
||||
let adj_id = {
|
||||
let (core, mut behavior) = adjustment_create();
|
||||
let a = behavior
|
||||
.as_any_mut()
|
||||
.and_then(|a| a.downcast_mut::<AdjustmentBlockBehavior>())
|
||||
.unwrap();
|
||||
a.core.range = TimeRange::new(Rational::new(220, 25), Rational::new(260, 25));
|
||||
a.core.track = Some(vtrack_id);
|
||||
p.graph.add_node(core, behavior)
|
||||
};
|
||||
|
||||
// Wire the hierarchy (behavior fields, the Rust model).
|
||||
{
|
||||
let entry = p.graph.get_mut(seq_id).unwrap();
|
||||
@@ -463,7 +478,7 @@ fn build_full_project() -> std::sync::Arc<std::sync::Mutex<oak_node::project::Pr
|
||||
.as_any_mut()
|
||||
.and_then(|a| a.downcast_mut::<TrackBehavior>())
|
||||
.unwrap();
|
||||
t.blocks = vec![clip1_id, gap1_id, clip2_id];
|
||||
t.blocks = vec![clip1_id, gap1_id, clip2_id, adj_id];
|
||||
}
|
||||
{
|
||||
let entry = p.graph.get_mut(alist_id).unwrap();
|
||||
@@ -675,9 +690,11 @@ fn assert_full_roundtrip_fields(orig: &oak_node::project::Project, loaded: &oak_
|
||||
let o_clip1 = o_t.blocks[0];
|
||||
let o_gap1 = o_t.blocks[1];
|
||||
let o_clip2 = o_t.blocks[2];
|
||||
let o_adj = o_t.blocks[3];
|
||||
let l_clip1 = l_t.blocks[0];
|
||||
let l_gap1 = l_t.blocks[1];
|
||||
let l_clip2 = l_t.blocks[2];
|
||||
let l_adj = l_t.blocks[3];
|
||||
|
||||
// Clip 1: range/media_in/speed/reversed/enabled/pitch/loop, track
|
||||
// and footage backrefs, and the effect connection.
|
||||
@@ -754,6 +771,31 @@ fn assert_full_roundtrip_fields(orig: &oak_node::project::Project, loaded: &oak_
|
||||
assert_eq!(l_c2.core.loop_mode, o_c2.core.loop_mode, "clip2 loop");
|
||||
assert_eq!(l_c2.footage, Some(l_footage), "clip2 footage backref");
|
||||
|
||||
// Adjustment layer: type, range and track backref.
|
||||
let o_a = orig
|
||||
.graph
|
||||
.get(o_adj)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<oak_node::block::AdjustmentBlockBehavior>())
|
||||
.unwrap();
|
||||
let l_a = loaded
|
||||
.graph
|
||||
.get(l_adj)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<oak_node::block::AdjustmentBlockBehavior>())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
loaded.graph.get(l_adj).unwrap().behavior.type_id(),
|
||||
"org.olivevideoeditor.Olive.adjustment",
|
||||
"adjustment type id"
|
||||
);
|
||||
assert_eq!(l_a.core.range, o_a.core.range, "adjustment range");
|
||||
assert_eq!(l_a.core.track, Some(l_vtrack), "adjustment track backref");
|
||||
|
||||
// Audio track + clip 3.
|
||||
let o_at = orig
|
||||
.graph
|
||||
|
||||
@@ -117,6 +117,7 @@ fn main() {
|
||||
// A single footage clip covers the whole timeline.
|
||||
footage: Some((media_clone, 0)),
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
}),
|
||||
audio: None,
|
||||
produce: Arc::new(|_, _| {
|
||||
|
||||
@@ -118,6 +118,7 @@ fn main() {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
}),
|
||||
audio: None,
|
||||
// Never invoked on the process backend (workers render from the
|
||||
|
||||
@@ -153,6 +153,7 @@ impl PreviewAutoCacher {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
},
|
||||
Box::new(|_| {}),
|
||||
);
|
||||
@@ -191,6 +192,7 @@ impl PreviewAutoCacher {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
},
|
||||
done,
|
||||
);
|
||||
|
||||
+739
-105
@@ -172,7 +172,13 @@ pub struct RenderEvalHooks {
|
||||
/// size as a paused full-res frame. `None` (non-sequence renders)
|
||||
/// falls back to the render-target size.
|
||||
pub sequence_size: Option<(i32, i32)>,
|
||||
|
||||
/// Position of the adjustment layer currently being swept, in
|
||||
/// `0.0..=1.0` (C++ the adjustment/transition `progress_in` uniform).
|
||||
/// [`render_graph_frame`] sets this for the duration of one adjustment
|
||||
/// block's evaluation, so a chain hanging off an adjustment layer can
|
||||
/// fade its result across the layer's span. Only shaders declaring a
|
||||
/// `progress_in` uniform consume it.
|
||||
pub layer_progress: Option<f64>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -283,6 +289,7 @@ impl RenderEvalHooks {
|
||||
ticket: None,
|
||||
frame_size: None,
|
||||
sequence_size: None,
|
||||
layer_progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,22 +820,42 @@ impl RenderEvalHooks {
|
||||
// visibly changes size whenever the transport stops. Pre-filling
|
||||
// the row wins over `run_effect`'s frame-size auto-fill; a node
|
||||
// that inserted its own `resolution_in` keeps it.
|
||||
let mut sequence_row;
|
||||
let params = if self.sequence_size.is_some()
|
||||
//
|
||||
// `progress_in` is the same pre-fill for the adjustment-layer
|
||||
// sweep: a shader declaring the uniform receives the layer
|
||||
// progress the graph driver recorded for this evaluation. The
|
||||
// row is only cloned when something actually needs inserting.
|
||||
let mut anchored_row;
|
||||
let needs_resolution = self.sequence_size.is_some()
|
||||
&& !payload.params.contains_key("resolution_in")
|
||||
&& compiled
|
||||
.translated
|
||||
.uniforms
|
||||
.iter()
|
||||
.any(|u| u.name == "resolution_in")
|
||||
{
|
||||
let (w, h) = self.sequence_size.unwrap();
|
||||
sequence_row = payload.params.clone();
|
||||
sequence_row.insert(
|
||||
"resolution_in".to_string(),
|
||||
NodeValue::Vec2([w as f64, h as f64]),
|
||||
);
|
||||
&sequence_row
|
||||
.any(|u| u.name == "resolution_in");
|
||||
let needs_progress = self.layer_progress.is_some()
|
||||
&& !payload.params.contains_key("progress_in")
|
||||
&& compiled
|
||||
.translated
|
||||
.uniforms
|
||||
.iter()
|
||||
.any(|u| u.name == "progress_in");
|
||||
let params = if needs_resolution || needs_progress {
|
||||
anchored_row = payload.params.clone();
|
||||
if needs_resolution {
|
||||
let (w, h) = self.sequence_size.unwrap();
|
||||
anchored_row.insert(
|
||||
"resolution_in".to_string(),
|
||||
NodeValue::Vec2([w as f64, h as f64]),
|
||||
);
|
||||
}
|
||||
if needs_progress {
|
||||
anchored_row.insert(
|
||||
"progress_in".to_string(),
|
||||
NodeValue::Float(self.layer_progress.unwrap()),
|
||||
);
|
||||
}
|
||||
&anchored_row
|
||||
} else {
|
||||
&payload.params
|
||||
};
|
||||
@@ -1396,6 +1423,253 @@ fn composite_tracks(frames: Vec<Frame>, size: (i32, i32)) -> Frame {
|
||||
acc
|
||||
}
|
||||
|
||||
/// One video track's contribution to [`render_graph_frame`], resolved
|
||||
/// before any evaluation so the project lock is only borrowed immutably
|
||||
/// (the adjustment sweep needs `&mut` on the very same graph).
|
||||
enum TrackRenderStep {
|
||||
/// The track's enabled clips covering the frame time.
|
||||
Clips(Vec<oak_node::id::NodeId>),
|
||||
/// The track's enabled transition block covering the frame time: the
|
||||
/// two blocks it joins are evaluated at `time` and blended with the
|
||||
/// transition's shader. `progress` is the block's position across its
|
||||
/// own span, in `0..=1` (`0.0` shows `out_block`, `1.0` shows
|
||||
/// `in_block`); `shader` is the style id the block's `type_in` combo
|
||||
/// selects.
|
||||
Transition {
|
||||
block: oak_node::id::NodeId,
|
||||
out_block: oak_node::id::NodeId,
|
||||
in_block: oak_node::id::NodeId,
|
||||
progress: f64,
|
||||
shader: &'static str,
|
||||
},
|
||||
/// The track's enabled adjustment block covering the frame time: run
|
||||
/// its effect chain over every frame collected below and let the
|
||||
/// result replace them (C++ adjustment layers affect everything
|
||||
/// underneath). `progress` is the block's position across its own
|
||||
/// span, in `0..=1`.
|
||||
Adjustment {
|
||||
block: oak_node::id::NodeId,
|
||||
progress: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Where `time` sits inside `in_..out`, in `0..=1` (the adjustment
|
||||
/// layer's `progress_in`). A degenerate span reports 0.
|
||||
fn layer_progress(in_: Rational, out: Rational, time: Rational) -> f64 {
|
||||
let (in_, out, time) = (in_.to_f64(), out.to_f64(), time.to_f64());
|
||||
if out > in_ {
|
||||
((time - in_) / (out - in_)).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate one block at `time` and read its texture back to a CPU
|
||||
/// frame. Every non-texture outcome — the evaluator produced no texture
|
||||
/// channel, the handle is null, or the read-back failed — is `Ok(None)`
|
||||
/// so a caller can fall back instead of failing the whole frame.
|
||||
fn evaluate_block_frame(
|
||||
graph: &oak_node::graph::Graph,
|
||||
traverser: &mut oak_node::traverser::Traverser,
|
||||
hooks: &mut RenderEvalHooks,
|
||||
block: oak_node::id::NodeId,
|
||||
time: Rational,
|
||||
) -> Result<Option<Frame>> {
|
||||
let request = oak_node::traverser::EvalRequest::new(block, time);
|
||||
let table = traverser.evaluate(graph, &request, hooks).map_err(|e| {
|
||||
Error::Failed(format!(
|
||||
"graph evaluation of block {block:?} failed: {e:?}"
|
||||
))
|
||||
})?;
|
||||
let Some(NodeValue::Texture(handle)) = table.get(oak_node::value::ValueType::Texture) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if handle.ctx.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(texture) = (unsafe { oak_node::handle::get_checked::<Texture>(handle) }).cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
match texture.to_frame() {
|
||||
Ok(frame) => Ok(Some(frame)),
|
||||
Err(err) => {
|
||||
eprintln!("graph sequence: texture read-back failed: {err:#}");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend the two sides of a transition block at `time` with the block's
|
||||
/// style shader: evaluate both sides, box a [`ShaderJobPayload`] carrying
|
||||
/// the pair and the progress factor, and run it through the render
|
||||
/// seam's shader-job path (the transition node behavior supplies the
|
||||
/// fragment source for the shader id). `None` when a side produced no
|
||||
/// texture or the job could not run (no GPU context, compile failure) —
|
||||
/// the caller then falls back to the side that natively covers `time`.
|
||||
fn blend_transition(
|
||||
graph: &oak_node::graph::Graph,
|
||||
traverser: &mut oak_node::traverser::Traverser,
|
||||
hooks: &mut RenderEvalHooks,
|
||||
out_block: oak_node::id::NodeId,
|
||||
in_block: oak_node::id::NodeId,
|
||||
time: Rational,
|
||||
progress: f64,
|
||||
shader: &str,
|
||||
) -> Result<Option<Frame>> {
|
||||
use oak_node::nodes::transitions;
|
||||
use oak_node::value::NodeValueRow;
|
||||
|
||||
let from = evaluate_block_frame(graph, traverser, hooks, out_block, time)?;
|
||||
let to = evaluate_block_frame(graph, traverser, hooks, in_block, time)?;
|
||||
|
||||
// Both sides present: blend them. The side frames are boxed as CPU
|
||||
// textures (the shader-job path uploads those into scratch textures
|
||||
// for the pass and releases them afterwards), so the originals stay
|
||||
// around for the fallback below.
|
||||
let blended = match (from.as_ref(), to.as_ref()) {
|
||||
(Some(from), Some(to)) => {
|
||||
let payload = ShaderJobPayload {
|
||||
node_id: oak_node::id::NodeId::INVALID,
|
||||
time,
|
||||
iterations: 1,
|
||||
type_id: "org.olivevideoeditor.Olive.transition".to_string(),
|
||||
shader_id: shader.to_string(),
|
||||
effect_input: String::new(),
|
||||
params: NodeValueRow::from([
|
||||
(
|
||||
transitions::TEXTURE_INPUT.to_string(),
|
||||
texture_value(Texture::wrap_frame(from.clone())),
|
||||
),
|
||||
(
|
||||
transitions::BLEND_INPUT.to_string(),
|
||||
texture_value(Texture::wrap_frame(to.clone())),
|
||||
),
|
||||
(
|
||||
transitions::PROGRESS_INPUT.to_string(),
|
||||
NodeValue::Float(progress),
|
||||
),
|
||||
]),
|
||||
iterative_input: String::new(),
|
||||
};
|
||||
match hooks.process_shader_job(&payload) {
|
||||
Some(texture) => match texture.to_frame() {
|
||||
Ok(frame) => Some(frame),
|
||||
Err(err) => {
|
||||
eprintln!("graph sequence: transition read-back failed: {err:#}");
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Fallback: without a blend (a missing side, or a shader job that
|
||||
// could not run) show the side that natively covers `time` — the
|
||||
// outgoing block before the cut, the incoming one at or after it.
|
||||
Ok(blended.or_else(|| if progress < 0.5 { from } else { to }))
|
||||
}
|
||||
|
||||
/// Walk an adjustment block's effect chain to its head: the first node
|
||||
/// whose effect input has no upstream — the node a sweep must feed the
|
||||
/// composited lower layers into. Returns that node and the input id to
|
||||
/// connect on it. `None` when there is no chain (a bare adjustment layer
|
||||
/// contributes nothing and is skipped entirely) or when the walk cannot
|
||||
/// reach a head (unconnected effect input, or a cycle).
|
||||
fn adjustment_chain_head(
|
||||
graph: &oak_node::graph::Graph,
|
||||
block: oak_node::id::NodeId,
|
||||
) -> Option<(oak_node::id::NodeId, String)> {
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
let mut node = block;
|
||||
loop {
|
||||
if !visited.insert(node) {
|
||||
return None;
|
||||
}
|
||||
let entry = graph.get(node)?;
|
||||
let input = entry.core.effect_input.clone();
|
||||
if input.is_empty() || entry.core.get_input(&input).is_none() {
|
||||
return None;
|
||||
}
|
||||
match graph.connected_output(node, &input, -1) {
|
||||
Some(upstream) => node = upstream,
|
||||
// The block's own effect input is open: nothing to run.
|
||||
None if node == block => return None,
|
||||
None => return Some((node, input)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the effect chain of the adjustment `block` over `below` (the
|
||||
/// frames of every track underneath it, topmost first): composite them,
|
||||
/// stand up a temporary texture source holding the result, wire it into
|
||||
/// the chain head, evaluate the block, and tear the temporary node back
|
||||
/// down before returning — the graph is part of the live project, so
|
||||
/// anything inspecting it concurrently must never see a half-wired
|
||||
/// sweep.
|
||||
///
|
||||
/// `Ok(None)` means the boundary changes nothing (no effect chain, or the
|
||||
/// chain produced no readable texture); `Ok(Some(frame))` is the chain's
|
||||
/// output, which replaces the lower layers.
|
||||
fn flush_adjustment_layer(
|
||||
graph: &mut oak_node::graph::Graph,
|
||||
traverser: &mut oak_node::traverser::Traverser,
|
||||
hooks: &mut RenderEvalHooks,
|
||||
block: oak_node::id::NodeId,
|
||||
below: &[Frame],
|
||||
size: (i32, i32),
|
||||
time: Rational,
|
||||
progress: f64,
|
||||
) -> Result<Option<Frame>> {
|
||||
let Some((head, head_input)) = adjustment_chain_head(graph, block) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let below = composite_tracks(below.to_vec(), size);
|
||||
let (core, behavior) = oak_node::nodes::compositesource::create();
|
||||
let source = graph.add_node(core, behavior);
|
||||
if let Some(entry) = graph.get_mut(source) {
|
||||
entry.core.set_standard_value(
|
||||
oak_node::nodes::compositesource::TEXTURE_INPUT,
|
||||
-1,
|
||||
texture_value(Texture::wrap_frame(below)),
|
||||
);
|
||||
}
|
||||
if let Err(err) = graph.connect(source, head, &head_input, -1) {
|
||||
let _ = graph.remove_node(source);
|
||||
return Err(Error::Failed(format!(
|
||||
"adjustment layer {block:?}: cannot feed {head_input} of {head:?}: {err:?}"
|
||||
)));
|
||||
}
|
||||
hooks.layer_progress = Some(progress);
|
||||
let evaluated =
|
||||
traverser.evaluate(graph, &oak_node::traverser::EvalRequest::new(block, time), hooks);
|
||||
hooks.layer_progress = None;
|
||||
let _ = graph.remove_node(source);
|
||||
let table = evaluated.map_err(|e| {
|
||||
Error::Failed(format!(
|
||||
"graph evaluation of adjustment layer {block:?} failed: {e:?}"
|
||||
))
|
||||
})?;
|
||||
let Some(NodeValue::Texture(handle)) = table.get(oak_node::value::ValueType::Texture) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if handle.ctx.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(texture) = (unsafe { oak_node::handle::get_checked::<Texture>(handle) }).cloned()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
match texture.to_frame() {
|
||||
Ok(frame) => Ok(Some(frame)),
|
||||
Err(err) => {
|
||||
eprintln!("graph sequence: adjustment layer read-back failed: {err:#}");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render one frame of `viewer` (a sequence) at `time`: evaluate every
|
||||
/// enabled clip overlapping `time` through the node graph (one traverser
|
||||
/// pass per clip; the hooks' decoder cache is shared across clips) and
|
||||
@@ -1403,6 +1677,19 @@ fn composite_tracks(frames: Vec<Frame>, size: (i32, i32)) -> Frame {
|
||||
/// list the LAST track (the highest-numbered one) is the topmost stack
|
||||
/// element (NLE stacking, matching the timeline UI).
|
||||
///
|
||||
/// A track whose enabled adjustment block covers `time` contributes an
|
||||
/// adjustment sweep (see [`flush_adjustment_layer`]) instead of its clips:
|
||||
/// everything collected below is composited and pushed through the block's
|
||||
/// effect chain, and the result replaces the layer stack underneath, so a
|
||||
/// single block affects every lower track at once.
|
||||
///
|
||||
/// A track whose enabled transition block covers `time` contributes one
|
||||
/// blended frame (see [`blend_transition`]) instead of the clip that
|
||||
/// covers `time` on its own: both blocks the transition joins are
|
||||
/// evaluated and mixed by the style shader the block's `type_in` combo
|
||||
/// selects. An adjustment block still wins over a transition on the same
|
||||
/// track.
|
||||
///
|
||||
/// `size` is the decode target for every clip, so all frames composite
|
||||
/// without per-frame scaling. Errors: `Invalid` for a non-F32 format or a
|
||||
/// non-positive size, `NotFound` for a missing viewer or non-sequence.
|
||||
@@ -1420,62 +1707,166 @@ pub fn render_graph_frame(
|
||||
if w <= 0 || h <= 0 {
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
let graph = &project.lock().unwrap().graph;
|
||||
let entry = graph.get(viewer).ok_or(Error::NotFound)?;
|
||||
let sequence = entry
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<oak_node::sequence::SequenceBehavior>())
|
||||
.ok_or(Error::NotFound)?;
|
||||
let mut project_guard = project.lock().unwrap();
|
||||
|
||||
// Collect the clips covering `time`: the sequence's track lists (video
|
||||
// then audio — C++ `Sequence` keeps them in the `k_track_input_format`
|
||||
// array order), the video list's tracks topmost-first (the list's last
|
||||
// track is the top of the stack; `composite_tracks` walks the frames in
|
||||
// reverse and draws the first frame last), then each track's blocks.
|
||||
let mut clips: Vec<oak_node::id::NodeId> = Vec::new();
|
||||
for tl_id in &sequence.track_lists {
|
||||
let Some(tl) = graph.get(*tl_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(tl) = tl
|
||||
// Plan the video tracks bottommost-first, one step per track: the
|
||||
// sequence's track lists (video then audio — C++ `Sequence` keeps them
|
||||
// in the `k_track_input_format` array order), the video list's tracks
|
||||
// in stacking order (the list's last track is the top of the stack;
|
||||
// `composite_tracks` walks the frames in reverse and draws the first
|
||||
// frame last), then each track's blocks. The plan holds plain ids so
|
||||
// the evaluation pass below can borrow the graph mutably for an
|
||||
// adjustment sweep's temporary source node.
|
||||
let (steps, sequence_size) = {
|
||||
let graph = &project_guard.graph;
|
||||
let entry = graph.get(viewer).ok_or(Error::NotFound)?;
|
||||
let sequence = entry
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<oak_node::track::TrackListBehavior>())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if tl.kind != oak_node::track::TrackType::Video {
|
||||
continue;
|
||||
}
|
||||
for track_id in tl.tracks.iter().rev() {
|
||||
let Some(track) = graph.get(*track_id) else {
|
||||
.and_then(|a| a.downcast_ref::<oak_node::sequence::SequenceBehavior>())
|
||||
.ok_or(Error::NotFound)?;
|
||||
|
||||
let mut steps: Vec<TrackRenderStep> = Vec::new();
|
||||
for tl_id in &sequence.track_lists {
|
||||
let Some(tl) = graph.get(*tl_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(track) = track
|
||||
let Some(tl) = tl
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<oak_node::track::TrackBehavior>())
|
||||
.and_then(|a| a.downcast_ref::<oak_node::track::TrackListBehavior>())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for block_id in &track.blocks {
|
||||
let Some(block) = graph.get(*block_id) else {
|
||||
if tl.kind != oak_node::track::TrackType::Video {
|
||||
continue;
|
||||
}
|
||||
for track_id in &tl.tracks {
|
||||
let Some(track) = graph.get(*track_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(clip) = block
|
||||
let Some(track) = track
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<oak_node::block::ClipBlockBehavior>())
|
||||
.and_then(|a| a.downcast_ref::<oak_node::track::TrackBehavior>())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if clip.core.enabled && time >= clip.core.in_() && time < clip.core.out() {
|
||||
clips.push(*block_id);
|
||||
// An enabled adjustment block covering `time` takes over
|
||||
// the track: its sweep replaces that track's clip stack.
|
||||
let mut step = None;
|
||||
for block_id in &track.blocks {
|
||||
let Some(block) = graph.get(*block_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(adjustment) = block.behavior.as_any().and_then(|a| {
|
||||
a.downcast_ref::<oak_node::block::AdjustmentBlockBehavior>()
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
if adjustment.core.enabled
|
||||
&& time >= adjustment.core.in_()
|
||||
&& time < adjustment.core.out()
|
||||
{
|
||||
step = Some(TrackRenderStep::Adjustment {
|
||||
block: *block_id,
|
||||
progress: layer_progress(
|
||||
adjustment.core.in_(),
|
||||
adjustment.core.out(),
|
||||
time,
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A transition block covering `time` blends the two blocks
|
||||
// it joins. The scan runs before the clip scan so the
|
||||
// blend replaces the plain clip read: in the first half of
|
||||
// the span the outgoing clip alone covers `time` (the cut
|
||||
// is its out-point), in the second the incoming one does,
|
||||
// so the clip path would otherwise hard-cut at the cut.
|
||||
// A block with an unconnected side falls through to the
|
||||
// clip path, which shows whichever clip covers `time`.
|
||||
if step.is_none() {
|
||||
for block_id in &track.blocks {
|
||||
let Some(block) = graph.get(*block_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(transition) = block.behavior.as_any().and_then(|a| {
|
||||
a.downcast_ref::<oak_node::block::TransitionBlockBehavior>()
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
if !(transition.core.enabled
|
||||
&& time >= transition.core.in_()
|
||||
&& time < transition.core.out())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (Some(out_block), Some(in_block)) = (
|
||||
graph.connected_output(
|
||||
*block_id,
|
||||
oak_node::block::transition_input::OUT_BLOCK,
|
||||
-1,
|
||||
),
|
||||
graph.connected_output(
|
||||
*block_id,
|
||||
oak_node::block::transition_input::IN_BLOCK,
|
||||
-1,
|
||||
),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let style = block
|
||||
.core
|
||||
.value_at_time(
|
||||
oak_node::block::transition_input::TYPE_INPUT,
|
||||
-1,
|
||||
time,
|
||||
)
|
||||
.to_double() as i64;
|
||||
step = Some(TrackRenderStep::Transition {
|
||||
block: *block_id,
|
||||
out_block,
|
||||
in_block,
|
||||
progress: layer_progress(
|
||||
transition.core.in_(),
|
||||
transition.core.out(),
|
||||
time,
|
||||
),
|
||||
shader: oak_node::nodes::transitions::shader_id_for(style),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
if step.is_none() {
|
||||
let mut clips: Vec<oak_node::id::NodeId> = Vec::new();
|
||||
for block_id in &track.blocks {
|
||||
let Some(block) = graph.get(*block_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(clip) = block
|
||||
.behavior
|
||||
.as_any()
|
||||
.and_then(|a| a.downcast_ref::<oak_node::block::ClipBlockBehavior>())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if clip.core.enabled && time >= clip.core.in_() && time < clip.core.out() {
|
||||
clips.push(*block_id);
|
||||
}
|
||||
}
|
||||
step = Some(TrackRenderStep::Clips(clips));
|
||||
}
|
||||
steps.push(step.unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
let sequence_size = sequence
|
||||
.video_params
|
||||
.first()
|
||||
.map(|p| (p.width.max(1), p.height.max(1)));
|
||||
(steps, sequence_size)
|
||||
};
|
||||
|
||||
let mut traverser = oak_node::traverser::Traverser::new();
|
||||
let mut hooks = RenderEvalHooks::new();
|
||||
@@ -1483,47 +1874,124 @@ pub fn render_graph_frame(
|
||||
// The generators' `resolution_in` anchor (C++ NodeGlobals square
|
||||
// resolution): the sequence's native size, so proxy-size playback and
|
||||
// full-res paused frames draw generated layers identically.
|
||||
hooks.sequence_size = sequence
|
||||
.video_params
|
||||
.first()
|
||||
.map(|p| (p.width.max(1), p.height.max(1)));
|
||||
hooks.sequence_size = sequence_size;
|
||||
let perf = std::env::var_os("OAK_PERF").is_some();
|
||||
let mut perf_collect = perf.then(std::time::Instant::now);
|
||||
let mut perf_collect_ms = 0.0f64;
|
||||
// Topmost frame first (see the track walk above).
|
||||
let mut frames: Vec<Frame> = Vec::new();
|
||||
let mut perf_clip_hist: Vec<(oak_node::id::NodeId, f64)> = Vec::new();
|
||||
for clip in clips.clone() {
|
||||
let clip_sw = perf.then(std::time::Instant::now);
|
||||
let request = oak_node::traverser::EvalRequest::new(clip, time);
|
||||
let table = traverser.evaluate(graph, &request, &mut hooks).map_err(|e| {
|
||||
Error::Failed(format!("graph evaluation of clip {clip:?} failed: {e:?}"))
|
||||
})?;
|
||||
if perf {
|
||||
perf_clip_hist.push((
|
||||
clip,
|
||||
clip_sw
|
||||
.map(|t| t.elapsed().as_secs_f64() * 1000.0)
|
||||
.unwrap_or(0.0),
|
||||
));
|
||||
}
|
||||
if let Some(t0) = &perf_collect {
|
||||
perf_collect_ms += t0.elapsed().as_secs_f64() * 1000.0;
|
||||
perf_collect = Some(std::time::Instant::now());
|
||||
}
|
||||
let Some(NodeValue::Texture(handle)) = table.get(oak_node::value::ValueType::Texture)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if handle.ctx.is_null() {
|
||||
continue;
|
||||
}
|
||||
let Some(texture) = (unsafe { oak_node::handle::get_checked::<Texture>(handle) }).cloned()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
match texture.to_frame() {
|
||||
Ok(frame) => frames.push(frame),
|
||||
Err(err) => eprintln!("graph sequence: texture read-back failed: {err:#}"),
|
||||
let mut perf_clip_hist: Vec<(&'static str, oak_node::id::NodeId, f64)> = Vec::new();
|
||||
for step in steps {
|
||||
match step {
|
||||
TrackRenderStep::Clips(clips) => {
|
||||
for clip in clips {
|
||||
let clip_sw = perf.then(std::time::Instant::now);
|
||||
let request = oak_node::traverser::EvalRequest::new(clip, time);
|
||||
let table = traverser
|
||||
.evaluate(&project_guard.graph, &request, &mut hooks)
|
||||
.map_err(|e| {
|
||||
Error::Failed(format!(
|
||||
"graph evaluation of clip {clip:?} failed: {e:?}"
|
||||
))
|
||||
})?;
|
||||
if perf {
|
||||
perf_clip_hist.push((
|
||||
"clip",
|
||||
clip,
|
||||
clip_sw
|
||||
.map(|t| t.elapsed().as_secs_f64() * 1000.0)
|
||||
.unwrap_or(0.0),
|
||||
));
|
||||
}
|
||||
if let Some(t0) = &perf_collect {
|
||||
perf_collect_ms += t0.elapsed().as_secs_f64() * 1000.0;
|
||||
perf_collect = Some(std::time::Instant::now());
|
||||
}
|
||||
let Some(NodeValue::Texture(handle)) =
|
||||
table.get(oak_node::value::ValueType::Texture)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if handle.ctx.is_null() {
|
||||
continue;
|
||||
}
|
||||
let Some(texture) =
|
||||
(unsafe { oak_node::handle::get_checked::<Texture>(handle) }).cloned()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
match texture.to_frame() {
|
||||
Ok(frame) => frames.insert(0, frame),
|
||||
Err(err) => {
|
||||
eprintln!("graph sequence: texture read-back failed: {err:#}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TrackRenderStep::Transition {
|
||||
block,
|
||||
out_block,
|
||||
in_block,
|
||||
progress,
|
||||
shader,
|
||||
} => {
|
||||
let transition_sw = perf.then(std::time::Instant::now);
|
||||
let blended = blend_transition(
|
||||
&project_guard.graph,
|
||||
&mut traverser,
|
||||
&mut hooks,
|
||||
out_block,
|
||||
in_block,
|
||||
time,
|
||||
progress,
|
||||
shader,
|
||||
)?;
|
||||
if perf {
|
||||
perf_clip_hist.push((
|
||||
"transition",
|
||||
block,
|
||||
transition_sw
|
||||
.map(|t| t.elapsed().as_secs_f64() * 1000.0)
|
||||
.unwrap_or(0.0),
|
||||
));
|
||||
}
|
||||
if let Some(t0) = &perf_collect {
|
||||
perf_collect_ms += t0.elapsed().as_secs_f64() * 1000.0;
|
||||
perf_collect = Some(std::time::Instant::now());
|
||||
}
|
||||
if let Some(frame) = blended {
|
||||
frames.insert(0, frame);
|
||||
}
|
||||
}
|
||||
TrackRenderStep::Adjustment { block, progress } => {
|
||||
let sweep_sw = perf.then(std::time::Instant::now);
|
||||
let swept = flush_adjustment_layer(
|
||||
&mut project_guard.graph,
|
||||
&mut traverser,
|
||||
&mut hooks,
|
||||
block,
|
||||
&frames,
|
||||
size,
|
||||
time,
|
||||
progress,
|
||||
)?;
|
||||
if perf {
|
||||
perf_clip_hist.push((
|
||||
"adjustment",
|
||||
block,
|
||||
sweep_sw
|
||||
.map(|t| t.elapsed().as_secs_f64() * 1000.0)
|
||||
.unwrap_or(0.0),
|
||||
));
|
||||
}
|
||||
if let Some(t0) = &perf_collect {
|
||||
perf_collect_ms += t0.elapsed().as_secs_f64() * 1000.0;
|
||||
perf_collect = Some(std::time::Instant::now());
|
||||
}
|
||||
if let Some(frame) = swept {
|
||||
frames = vec![frame];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1534,8 +2002,8 @@ pub fn render_graph_frame(
|
||||
let composite_ms = composite_started
|
||||
.map(|t| t.elapsed().as_secs_f64() * 1000.0)
|
||||
.unwrap_or(0.0);
|
||||
for (clip, ms) in &perf_clip_hist {
|
||||
eprintln!("[perf] clip {clip:?} evaluate {ms:.1}ms");
|
||||
for (kind, node, ms) in &perf_clip_hist {
|
||||
eprintln!("[perf] {kind} {node:?} evaluate {ms:.1}ms");
|
||||
}
|
||||
eprintln!(
|
||||
"[perf] graph frame time {time:?} size {size:?}: evaluate {perf_collect_ms:.1}ms composite {composite_ms:.1}ms total {}ms",
|
||||
@@ -1772,6 +2240,14 @@ fn render_montage_frame(
|
||||
/// shared-memory slot slice as `dst`, so the composited frame lands in
|
||||
/// the slot with no staging copy. `dst` is zeroed first (transparent
|
||||
/// black base).
|
||||
///
|
||||
/// Adjustment layers (`params.adjustments`, M14 W3) sit between the
|
||||
/// clips: a span's effect stack runs over the frame accumulated so far
|
||||
/// once `span.track_index` clips below it have been composited — the
|
||||
/// montage twin of the graph path's `flush_adjustment_layer`, which
|
||||
/// grades everything under the layer and leaves the tracks above
|
||||
/// compositing over that result. Spans arrive bottom-up (non-decreasing
|
||||
/// boundary); the loop consumes them in list order.
|
||||
pub fn render_montage_frame_into(
|
||||
time: Rational,
|
||||
params: &crate::ticket::VideoTicketParams,
|
||||
@@ -1786,8 +2262,15 @@ pub fn render_montage_frame_into(
|
||||
}
|
||||
// Transparent-black base.
|
||||
dst[..need].fill(0);
|
||||
let mut spans = params.adjustments.iter().peekable();
|
||||
// Decode from the bottom clip first, composite topmost-last.
|
||||
for clip in ¶ms.montage {
|
||||
for (index, clip) in params.montage.iter().enumerate() {
|
||||
// Every layer whose boundary is reached by the clips composited
|
||||
// so far grades the accumulated frame before this clip lands.
|
||||
while spans.peek().is_some_and(|span| span.track_index <= index) {
|
||||
let span = spans.next().expect("peeked");
|
||||
apply_adjustment_span(dst, dst_stride, w, h, span, time);
|
||||
}
|
||||
if time < clip.in_time || time >= clip.out_time {
|
||||
continue;
|
||||
}
|
||||
@@ -1809,9 +2292,119 @@ pub fn render_montage_frame_into(
|
||||
};
|
||||
composite_over(dst, dst_stride, w, h, src_data, src_stride, clip.gain);
|
||||
}
|
||||
// Layers above every clip (their boundary is the montage end): they
|
||||
// grade the final composite.
|
||||
for span in spans {
|
||||
apply_adjustment_span(dst, dst_stride, w, h, span, time);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply an adjustment layer's effect stack to the frame accumulated so
|
||||
/// far — the montage twin of the graph path's `flush_adjustment_layer`.
|
||||
/// The stack runs source-first over `dst` before the clips above the
|
||||
/// layer are composited, so the layer grades exactly the picture
|
||||
/// underneath it (C++: the adjustment node's `texture_input` chain
|
||||
/// output passes through its effect chain, and that output is what the
|
||||
/// tracks above composite over; a bare adjustment layer passes the
|
||||
/// frame through unchanged).
|
||||
///
|
||||
/// Known limitation (M14 W3 — the same one the clip stacks carry): the
|
||||
/// montage evaluator only covers the built-in Opacity effect and OFX
|
||||
/// plugins; other built-ins (color management, transforms, generated
|
||||
/// textures…) log a warning once and pass the frame through. Graph mode
|
||||
/// (`render_graph_frame`, M14 W2) evaluates the full built-in set, so a
|
||||
/// worker holding a matching project snapshot stays the accurate
|
||||
/// preview and this path is the montage fallback.
|
||||
fn apply_adjustment_span(
|
||||
dst: &mut [u8],
|
||||
dst_stride: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
span: &crate::ticket::AdjustmentSpan,
|
||||
time: Rational,
|
||||
) {
|
||||
// Spans are baked for the ticket's own time; one that does not cover
|
||||
// it (a stale or foreign list) is inert, matching the graph path's
|
||||
// range test.
|
||||
if time < span.in_time || time >= span.out_time {
|
||||
return;
|
||||
}
|
||||
// Fast path: an all-Opacity stack (the common case) scales the
|
||||
// accumulated frame in place, no staging copy — opacity is a pure
|
||||
// per-channel multiply, alpha included (C++ `:/shaders/opacity.frag`).
|
||||
let mut factors = Vec::new();
|
||||
let mut opacity_only = true;
|
||||
for effect in span.effects.iter().filter(|e| e.enabled) {
|
||||
match opacity_factor(effect) {
|
||||
Some(factor) => factors.push(factor),
|
||||
// Unity is a pass-through; any other type needs the staged
|
||||
// path below.
|
||||
None if effect.type_id == OPACITY_EFFECT_TYPE_ID => {}
|
||||
None => {
|
||||
opacity_only = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if opacity_only {
|
||||
for factor in factors {
|
||||
scale_channels_in_place(dst, dst_stride as usize, w, h, factor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// General path: the effect evaluator takes and returns an owned
|
||||
// texture, so stage the accumulated frame into one and copy the
|
||||
// result back.
|
||||
let Ok(mut frame) = generate_frame(time, (w, h), PixelFormat::F32) else {
|
||||
return;
|
||||
};
|
||||
let frame_stride = frame.linesize_bytes();
|
||||
if !copy_rows(
|
||||
&mut frame.data,
|
||||
frame_stride,
|
||||
dst,
|
||||
dst_stride as usize,
|
||||
w,
|
||||
h,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let tex = apply_effect_list(Texture::wrap_frame(frame), &span.effects, time);
|
||||
if let Texture::Cpu(out) = &tex {
|
||||
copy_rows(dst, dst_stride as usize, &out.data, out.linesize_bytes(), w, h);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy `h` rows of `w * 16` bytes between two F32 RGBA buffers with
|
||||
/// different strides (the montage pipeline writes packed frames, slots
|
||||
/// carry their own stride). False — nothing copied — when either buffer
|
||||
/// is too small for the requested geometry.
|
||||
fn copy_rows(
|
||||
dst: &mut [u8],
|
||||
dst_stride: usize,
|
||||
src: &[u8],
|
||||
src_stride: usize,
|
||||
w: i32,
|
||||
h: i32,
|
||||
) -> bool {
|
||||
if w <= 0 || h <= 0 {
|
||||
return false;
|
||||
}
|
||||
let row_bytes = (w as usize) * 16;
|
||||
let rows = h as usize;
|
||||
let span = |stride: usize| (rows - 1) * stride + row_bytes;
|
||||
if src.len() < span(src_stride) || dst.len() < span(dst_stride) {
|
||||
return false;
|
||||
}
|
||||
for y in 0..rows {
|
||||
let s = y * src_stride;
|
||||
let d = y * dst_stride;
|
||||
dst[d..d + row_bytes].copy_from_slice(&src[s..s + row_bytes]);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `src` over `dst` (premultiplied-ish alpha compositing; F32 RGBA).
|
||||
/// `gain` scales the source RGB (audio-style volume applied to video
|
||||
/// transparency is ignored here; gain scales color). Exposed for the M15
|
||||
@@ -1902,9 +2495,19 @@ fn apply_clip_effects(
|
||||
src: Texture,
|
||||
clip: &crate::ticket::MontageClip,
|
||||
time: Rational,
|
||||
) -> Texture {
|
||||
apply_effect_list(src, &clip.effects, time)
|
||||
}
|
||||
|
||||
/// Run an effect stack (source-first) over `src`; the clip stacks and the
|
||||
/// adjustment-layer stacks share this evaluator.
|
||||
fn apply_effect_list(
|
||||
src: Texture,
|
||||
effects: &[crate::ticket::MontageEffect],
|
||||
time: Rational,
|
||||
) -> Texture {
|
||||
let mut tex = src;
|
||||
for effect in &clip.effects {
|
||||
for effect in effects {
|
||||
if !effect.enabled {
|
||||
continue;
|
||||
}
|
||||
@@ -1913,6 +2516,49 @@ fn apply_clip_effects(
|
||||
tex
|
||||
}
|
||||
|
||||
/// The factor an Opacity effect would scale its input by: `None` when
|
||||
/// the effect is not the built-in Opacity or its factor is unity (C++
|
||||
/// `qFuzzyCompare(opacity, 1.0)`, a pass-through).
|
||||
fn opacity_factor(effect: &crate::ticket::MontageEffect) -> Option<f32> {
|
||||
if effect.type_id != OPACITY_EFFECT_TYPE_ID {
|
||||
return None;
|
||||
}
|
||||
let factor = effect
|
||||
.params
|
||||
.iter()
|
||||
.find(|(id, _)| id == OPACITY_VALUE_INPUT)
|
||||
.map(|(_, v)| v.to_double())
|
||||
.unwrap_or(1.0);
|
||||
if (factor - 1.0).abs() * 1e12 <= factor.abs().min(1.0) {
|
||||
return None;
|
||||
}
|
||||
Some(factor as f32)
|
||||
}
|
||||
|
||||
/// Scale every F32 channel (alpha included) of an F32 RGBA frame by
|
||||
/// `factor`, in place — the Opacity shader's `frag_color * opacity_in`.
|
||||
fn scale_channels_in_place(
|
||||
data: &mut [u8],
|
||||
stride: usize,
|
||||
width: i32,
|
||||
height: i32,
|
||||
factor: f32,
|
||||
) {
|
||||
if stride == 0 || width <= 0 || height <= 0 {
|
||||
return;
|
||||
}
|
||||
let row_bytes = (width as usize) * 16;
|
||||
for row in data.chunks_exact_mut(stride).take(height as usize) {
|
||||
let row_end = row_bytes.min(row.len());
|
||||
for px in row[..row_end].chunks_exact_mut(16) {
|
||||
for c in px.chunks_exact_mut(4) {
|
||||
let v = f32::from_le_bytes(c.try_into().unwrap());
|
||||
c.copy_from_slice(&(v * factor).to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one effect to `src` (an F32 RGBA CPU frame of the montage
|
||||
/// pipeline). `time` is the sequence time the frame is rendered at (the
|
||||
/// C++ node evaluation time).
|
||||
@@ -1925,30 +2571,17 @@ fn apply_montage_effect(
|
||||
// (C++ `:/shaders/opacity.frag`: `frag_color = texture(tex_in, …) *
|
||||
// opacity_in` — the shader scales the whole vec4, alpha included).
|
||||
if effect.type_id == OPACITY_EFFECT_TYPE_ID {
|
||||
let factor = effect
|
||||
.params
|
||||
.iter()
|
||||
.find(|(id, _)| id == OPACITY_VALUE_INPUT)
|
||||
.map(|(_, v)| v.to_double())
|
||||
.unwrap_or(1.0);
|
||||
// Unity is a pass-through (C++ `qFuzzyCompare(opacity, 1.0)`).
|
||||
if (factor - 1.0).abs() * 1e12 <= factor.abs().min(1.0) {
|
||||
let Some(factor) = opacity_factor(effect) else {
|
||||
return src;
|
||||
}
|
||||
};
|
||||
// Texture implements Drop, so scale in place through a mutable
|
||||
// borrow instead of moving the frame out.
|
||||
let mut tex = src;
|
||||
match &mut tex {
|
||||
Texture::Cpu(frame) => {
|
||||
let stride = frame.linesize_bytes();
|
||||
for row in frame.data.chunks_exact_mut(stride).take(frame.height.max(0) as usize) {
|
||||
for px in row[..(frame.width.max(0) as usize) * 16].chunks_exact_mut(16) {
|
||||
for c in px.chunks_exact_mut(4) {
|
||||
let v = f32::from_le_bytes(c.try_into().unwrap());
|
||||
c.copy_from_slice(&(v * factor as f32).to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
scale_channels_in_place(&mut frame.data, stride, frame.width, frame.height, factor);
|
||||
}
|
||||
_ => {
|
||||
warn_unsupported_once(
|
||||
@@ -2033,6 +2666,7 @@ mod tests {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
};
|
||||
let tex = render_produced_frame(params.time, ¶ms).unwrap();
|
||||
assert_eq!(tex.size(), (16, 9));
|
||||
|
||||
@@ -453,6 +453,49 @@ pub fn montage_effect_from(wire: &WireMontageEffect) -> crate::ticket::MontageEf
|
||||
}
|
||||
}
|
||||
|
||||
/// One adjustment layer of a montage on the wire (protocol v2 additive
|
||||
/// field of [`BatchTicketSpec`]; rationals flattened to num/den pairs).
|
||||
/// `track_index` is the montage-list boundary of
|
||||
/// [`crate::ticket::AdjustmentSpan`], not a track number.
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct WireAdjustmentSpan {
|
||||
/// Adjustment in point numerator (sequence time).
|
||||
pub in_num: i64,
|
||||
/// Adjustment in point denominator.
|
||||
pub in_den: i64,
|
||||
/// Adjustment out point numerator (sequence time).
|
||||
pub out_num: i64,
|
||||
/// Adjustment out point denominator.
|
||||
pub out_den: i64,
|
||||
/// Montage-list boundary (clips composited below this layer).
|
||||
pub track_index: usize,
|
||||
/// The adjustment's effect stack (source-first).
|
||||
pub effects: Vec<WireMontageEffect>,
|
||||
}
|
||||
|
||||
/// Map a ticket-side adjustment span to its wire form (main process).
|
||||
pub fn wire_adjustment_from(span: &crate::ticket::AdjustmentSpan) -> WireAdjustmentSpan {
|
||||
WireAdjustmentSpan {
|
||||
in_num: span.in_time.numerator(),
|
||||
in_den: span.in_time.denominator(),
|
||||
out_num: span.out_time.numerator(),
|
||||
out_den: span.out_time.denominator(),
|
||||
track_index: span.track_index,
|
||||
effects: span.effects.iter().map(wire_effect_from).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a wire adjustment span back to the ticket-side form (worker).
|
||||
pub fn adjustment_from_wire(wire: &WireAdjustmentSpan) -> crate::ticket::AdjustmentSpan {
|
||||
crate::ticket::AdjustmentSpan {
|
||||
in_time: oak_core::Rational::new(wire.in_num, wire.in_den),
|
||||
out_time: oak_core::Rational::new(wire.out_num, wire.out_den),
|
||||
track_index: wire.track_index,
|
||||
effects: wire.effects.iter().map(montage_effect_from).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame ticket inside a [`RenderBatchMsg`] — the main process
|
||||
/// assigns the destination `slot`.
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
|
||||
@@ -480,6 +523,9 @@ pub struct BatchTicketSpec {
|
||||
pub footage_stream: i32,
|
||||
/// Sequence montage (ordered topmost-last; empty = none).
|
||||
pub montage: Vec<WireMontageClip>,
|
||||
/// Adjustment layers over the montage (protocol v2 additive: older peers
|
||||
/// omit the field and it defaults to no adjustment layers).
|
||||
pub adjustments: Vec<WireAdjustmentSpan>,
|
||||
/// Sequence viewer node identity (0 = montage mode; nonzero = render
|
||||
/// the viewer's graph frame from the worker's loaded snapshot).
|
||||
pub viewer_node: u64,
|
||||
@@ -1976,6 +2022,7 @@ mod tests {
|
||||
footage_file: "a.mp4".into(),
|
||||
footage_stream: 0,
|
||||
montage: vec![],
|
||||
adjustments: vec![],
|
||||
viewer_node: 0,
|
||||
project_key: String::new(),
|
||||
},
|
||||
@@ -2010,6 +2057,22 @@ mod tests {
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
adjustments: vec![WireAdjustmentSpan {
|
||||
in_num: 0,
|
||||
in_den: 24,
|
||||
out_num: 48,
|
||||
out_den: 24,
|
||||
track_index: 1,
|
||||
effects: vec![WireMontageEffect {
|
||||
type_id: "org.olivevideoeditor.Olive.opacity".into(),
|
||||
enabled: true,
|
||||
effect_input_id: "tex_in".into(),
|
||||
params: vec![WireEffectParam {
|
||||
input: "opacity_in".into(),
|
||||
value: WireNodeValue::Float(0.5),
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
viewer_node: 0,
|
||||
project_key: String::new(),
|
||||
},
|
||||
@@ -2031,6 +2094,44 @@ mod tests {
|
||||
value["tickets"][1]["montage"][0]["effects"][0]["params"][0]["value"],
|
||||
json!({ "t": "float", "v": 0.5 })
|
||||
);
|
||||
// The adjustment layer rides the ticket as an additive v2 field: its
|
||||
// boundary is a montage-list index, not a track number.
|
||||
assert_eq!(value["tickets"][1]["adjustments"][0]["track_index"], 1);
|
||||
assert_eq!(value["tickets"][1]["adjustments"][0]["out_num"], 48);
|
||||
assert_eq!(
|
||||
value["tickets"][1]["adjustments"][0]["effects"][0]["type_id"],
|
||||
"org.olivevideoeditor.Olive.opacity"
|
||||
);
|
||||
// The ticket-side conversions agree with the wire structs.
|
||||
let parsed: WireAdjustmentSpan =
|
||||
serde_json::from_value(value["tickets"][1]["adjustments"][0].clone()).unwrap();
|
||||
let span = adjustment_from_wire(&parsed);
|
||||
assert_eq!(span.in_time, oak_core::Rational::new(0, 24));
|
||||
assert_eq!(span.out_time, oak_core::Rational::new(48, 24));
|
||||
assert_eq!(span.track_index, 1);
|
||||
assert_eq!(
|
||||
span.effects[0].type_id,
|
||||
"org.olivevideoeditor.Olive.opacity"
|
||||
);
|
||||
assert_eq!(span.effects[0].effect_input_id.as_deref(), Some("tex_in"));
|
||||
assert_eq!(
|
||||
span.effects[0].params[0].1,
|
||||
oak_node::value::NodeValue::Float(0.5)
|
||||
);
|
||||
// `Rational::new` normalizes, so the wire form of the parsed span
|
||||
// collapses 48/24 to 2/1.
|
||||
let wire = wire_adjustment_from(&span);
|
||||
assert_eq!(
|
||||
wire,
|
||||
WireAdjustmentSpan {
|
||||
in_num: 0,
|
||||
in_den: 1,
|
||||
out_num: 2,
|
||||
out_den: 1,
|
||||
track_index: 1,
|
||||
effects: parsed.effects.clone(),
|
||||
}
|
||||
);
|
||||
// Round-trip back to the struct.
|
||||
let round: RenderBatchMsg = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(round, batch);
|
||||
@@ -2041,6 +2142,10 @@ mod tests {
|
||||
assert_eq!(bare.slot, 2);
|
||||
assert_eq!(bare.format, 0);
|
||||
assert!(bare.montage.is_empty());
|
||||
assert!(
|
||||
bare.adjustments.is_empty(),
|
||||
"v1 tickets carry no adjustment layers"
|
||||
);
|
||||
}
|
||||
|
||||
/// Protocol v2 compatibility: a v1-shaped montage clip (no `effects`
|
||||
|
||||
@@ -2471,6 +2471,11 @@ fn build_ticket_spec(
|
||||
footage_file,
|
||||
footage_stream,
|
||||
montage,
|
||||
adjustments: params
|
||||
.adjustments
|
||||
.iter()
|
||||
.map(crate::ipc::wire_adjustment_from)
|
||||
.collect(),
|
||||
// M16 S1 graph mode: the worker renders the viewer's graph frame
|
||||
// when nonzero (else the montage path above) — and only when the
|
||||
// ticket's project matches the worker's loaded snapshot (the
|
||||
|
||||
@@ -82,6 +82,34 @@ pub struct MontageClip {
|
||||
pub effects: Vec<MontageEffect>,
|
||||
}
|
||||
|
||||
/// One adjustment layer of a sequence montage (M14 W3): a time range
|
||||
/// whose effect stack applies to everything composited below it.
|
||||
///
|
||||
/// The montage is a flat list, so the layer's position is expressed as a
|
||||
/// boundary into that list rather than as a track index: the renderer
|
||||
/// applies the span's effects once the accumulated frame holds
|
||||
/// `track_index` clips — i.e. before compositing the clip at that index
|
||||
/// (a `track_index` equal to the montage length applies after the last
|
||||
/// clip, the topmost adjustment layer). Spans arrive ordered
|
||||
/// bottom-up (non-decreasing `track_index`), matching the order the
|
||||
/// builder walks the track list in.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AdjustmentSpan {
|
||||
/// Adjustment in point (sequence time).
|
||||
pub in_time: Rational,
|
||||
/// Adjustment out point (sequence time).
|
||||
pub out_time: Rational,
|
||||
/// Montage-list boundary: the number of clips already composited
|
||||
/// below this layer. Effects apply to the accumulated frame just
|
||||
/// before the clip at this index is composited; `montage.len()`
|
||||
/// means "after every clip".
|
||||
pub track_index: usize,
|
||||
/// The adjustment's effect stack (source-first; empty for a bare
|
||||
/// adjustment layer, which passes the accumulated frame through
|
||||
/// unchanged).
|
||||
pub effects: Vec<MontageEffect>,
|
||||
}
|
||||
|
||||
/// Audio ticket parameters (M12 P1): the output format plus the audio
|
||||
/// montage to mix over the requested range.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -129,6 +157,10 @@ pub struct VideoTicketParams {
|
||||
/// Sequence montage (ordered topmost-last; M12 P0). When set, the
|
||||
/// footage field is ignored.
|
||||
pub montage: Vec<MontageClip>,
|
||||
/// Adjustment layers over the montage (M14 W3; montage twin of the
|
||||
/// graph path's adjustment sweep). Ordered bottom-up; empty means the
|
||||
/// sequence has no enabled adjustment layer over the rendered time.
|
||||
pub adjustments: Vec<AdjustmentSpan>,
|
||||
}
|
||||
|
||||
impl VideoTicketParams {
|
||||
@@ -574,6 +606,7 @@ impl TicketArena {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
}),
|
||||
audio: Some(ap_job),
|
||||
produce: producer,
|
||||
@@ -744,6 +777,7 @@ mod tests {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
},
|
||||
Box::new(move |r| {
|
||||
let _ = tx.send(r.is_ok());
|
||||
@@ -795,6 +829,7 @@ mod tests {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
},
|
||||
Box::new(move |r| {
|
||||
let _ = tx.send(r);
|
||||
@@ -881,6 +916,7 @@ mod tests {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
},
|
||||
Box::new(|_| {}),
|
||||
);
|
||||
@@ -897,6 +933,7 @@ mod tests {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
},
|
||||
Box::new(|_| {}),
|
||||
);
|
||||
|
||||
@@ -493,6 +493,7 @@ mod tests {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
}),
|
||||
audio: None,
|
||||
produce,
|
||||
@@ -564,6 +565,7 @@ mod tests {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
}),
|
||||
audio: None,
|
||||
produce: p,
|
||||
@@ -602,6 +604,7 @@ mod tests {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
});
|
||||
d.post(Job {
|
||||
node_identity: 0,
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Adjustment layers on the graph render path: a video track whose enabled
|
||||
//! adjustment block covers the request time composites every track
|
||||
//! underneath, pushes that composite through the block's effect chain and
|
||||
//! lets the chain's output replace the lower stack — one block affects all
|
||||
//! lower tracks, clip boundaries included. A bare block (no effect chain)
|
||||
//! has no head to feed and is inert.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use oak_core::texture::Texture;
|
||||
use oak_core::{PixelFormat, Rational, TimeRange};
|
||||
use oak_node::block::{AdjustmentBlockBehavior, ClipBlockBehavior};
|
||||
use oak_node::footage::FootageBehavior;
|
||||
use oak_node::id::NodeId;
|
||||
use oak_node::node::NodeCore;
|
||||
use oak_node::project::Project;
|
||||
use oak_node::sequence::SequenceBehavior;
|
||||
use oak_node::track::{TrackBehavior, TrackListBehavior};
|
||||
|
||||
mod common;
|
||||
|
||||
/// Unique temp path per test (the process id disambiguates parallel test
|
||||
/// binaries; the tag separates tests inside one binary).
|
||||
fn clip_path(tag: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("oakrender_adjust_{tag}_{}.mp4", std::process::id()))
|
||||
}
|
||||
|
||||
/// These tests verify graph/composite MECHANICS (the adjustment sweep), not
|
||||
/// the color pipeline. Pin the working space to the legacy sRGB
|
||||
/// pass-through so the decoded pixels stay display-referred and the
|
||||
/// pixel-value assertions hold regardless of the ACEScg default. All tests
|
||||
/// in this binary set the same value, so the shared global is race-free.
|
||||
fn pin_legacy_working_space() {
|
||||
oak_core::color::set_pipeline_color_settings(
|
||||
oak_core::colormath::WorkingColorSpace::SrgbLegacy,
|
||||
oak_core::colormath::OutputColorSpec::default(),
|
||||
);
|
||||
}
|
||||
|
||||
/// V1 (a single track carrying every clip of `clips`, in order) plus an
|
||||
/// optional V2 adjustment layer. V2 goes in last, so it is the top of the
|
||||
/// stack and sweeps V1. `adjustment` is `(in, out, opacity)`: `Some(opacity)`
|
||||
/// builds the effect chain (an Opacity node feeding the block's `tex_in`,
|
||||
/// its own `tex_in` left open as the chain head), `None` leaves the block
|
||||
/// bare.
|
||||
fn build_project(
|
||||
clips: &[(&str, Rational, Rational)],
|
||||
adjustment: Option<(Rational, Rational, Option<f64>)>,
|
||||
) -> (Arc<Mutex<Project>>, NodeId) {
|
||||
pin_legacy_working_space();
|
||||
let project = Project::new();
|
||||
let seq;
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
let (score, sbehavior) = SequenceBehavior::create();
|
||||
seq = p.graph.add_node(score, sbehavior);
|
||||
|
||||
let (tcore, tbehavior) = TrackListBehavior::create();
|
||||
let tl = p.graph.add_node(tcore, tbehavior);
|
||||
|
||||
let (tcore, tbehavior) = TrackBehavior::create();
|
||||
let v1 = p.graph.add_node(tcore, tbehavior);
|
||||
for &(path, in_, out) in clips {
|
||||
let mut footage = FootageBehavior::new(path);
|
||||
footage.probe().expect("probe the generated clip");
|
||||
let footage = p.graph.add_node(NodeCore::new(), Box::new(footage));
|
||||
|
||||
let (ccore, cbehavior) = oak_node::block::clip_create();
|
||||
let clip = p.graph.add_node(ccore, cbehavior);
|
||||
p.graph
|
||||
.connect(
|
||||
footage,
|
||||
clip,
|
||||
oak_node::block::clip_input::TEXTURE_INPUT,
|
||||
-1,
|
||||
)
|
||||
.expect("connect footage to clip");
|
||||
|
||||
p.graph
|
||||
.get_mut(clip)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<ClipBlockBehavior>()
|
||||
.expect("clip block")
|
||||
.core
|
||||
.range = TimeRange::new(in_, out);
|
||||
|
||||
p.graph
|
||||
.get_mut(v1)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TrackBehavior>()
|
||||
.expect("video track")
|
||||
.append_block(clip);
|
||||
}
|
||||
p.graph
|
||||
.get_mut(tl)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TrackListBehavior>()
|
||||
.expect("video track list")
|
||||
.tracks
|
||||
.push(v1);
|
||||
|
||||
if let Some((in_, out, opacity)) = adjustment {
|
||||
let (tcore, tbehavior) = TrackBehavior::create();
|
||||
let v2 = p.graph.add_node(tcore, tbehavior);
|
||||
|
||||
let (acore, abehavior) = oak_node::block::adjustment_create();
|
||||
let block = p.graph.add_node(acore, abehavior);
|
||||
p.graph
|
||||
.get_mut(block)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<AdjustmentBlockBehavior>()
|
||||
.expect("adjustment block")
|
||||
.core
|
||||
.range = TimeRange::new(in_, out);
|
||||
|
||||
if let Some(value) = opacity {
|
||||
let (ecore, ebehavior) = oak_node::nodes::opacity::create();
|
||||
let effect = p.graph.add_node(ecore, ebehavior);
|
||||
p.graph
|
||||
.connect(
|
||||
effect,
|
||||
block,
|
||||
oak_node::block::adjustment_input::TEXTURE_INPUT,
|
||||
-1,
|
||||
)
|
||||
.expect("connect opacity to the adjustment block");
|
||||
p.graph.get_mut(effect).unwrap().core.set_standard_value(
|
||||
oak_node::nodes::opacity::VALUE_INPUT,
|
||||
-1,
|
||||
oak_node::value::NodeValue::Float(value),
|
||||
);
|
||||
}
|
||||
|
||||
p.graph
|
||||
.get_mut(v2)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TrackBehavior>()
|
||||
.expect("video track")
|
||||
.append_block(block);
|
||||
p.graph
|
||||
.get_mut(tl)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TrackListBehavior>()
|
||||
.expect("video track list")
|
||||
.tracks
|
||||
.push(v2);
|
||||
}
|
||||
|
||||
p.graph
|
||||
.get_mut(seq)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<SequenceBehavior>()
|
||||
.expect("sequence")
|
||||
.track_lists
|
||||
.push(tl);
|
||||
}
|
||||
(project, seq)
|
||||
}
|
||||
|
||||
/// The raw CPU frame bytes of a rendered texture.
|
||||
fn frame_data(texture: &Texture) -> &[u8] {
|
||||
let Texture::Cpu(frame) = texture else {
|
||||
panic!("graph render produced a non-CPU texture");
|
||||
};
|
||||
&frame.data
|
||||
}
|
||||
|
||||
/// Render one 64x64 F32 frame of `seq` at `time` and return its bytes.
|
||||
fn render_frame(project: &Arc<Mutex<Project>>, seq: NodeId, time: Rational) -> Vec<u8> {
|
||||
let texture =
|
||||
oak_render::eval::render_graph_frame(project, seq, time, (64, 64), PixelFormat::F32)
|
||||
.expect("graph render");
|
||||
frame_data(&texture).to_vec()
|
||||
}
|
||||
|
||||
/// The F32 RGBA channel of a 64x64 frame at `(x, y)`.
|
||||
fn channel(data: &[u8], x: usize, y: usize, c: usize) -> f32 {
|
||||
let off = (y * 64 + x) * 16 + c * 4;
|
||||
f32::from_le_bytes(data[off..off + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// An adjustment layer over a single V1 track whose two solid clips abut at
|
||||
/// t=1/2: inside the layer's span BOTH clips come out of the effect chain
|
||||
/// (the red clip on the left half, the blue one on the right half of the
|
||||
/// span), so one block reaches across the clip boundary; outside the span
|
||||
/// the render is byte-identical to the same project without the layer.
|
||||
///
|
||||
/// The chain is an Opacity node at 0.5, which scales the straight-alpha
|
||||
/// vec4 by 0.5; the frame then goes through the alpha-over composite once
|
||||
/// more (like `shader_job_opacity_halves_pixels`), so each color channel
|
||||
/// ends up at a 0.25 ratio of the plain render and the alpha at 0.5.
|
||||
/// Skipped (with a note) when no GPU adapter exists.
|
||||
#[test]
|
||||
fn adjustment_layer_affects_lower_tracks_across_clips() {
|
||||
if oak_core::backend::GpuContext::shared().is_none() {
|
||||
eprintln!("skipping adjustment_layer_affects_lower_tracks_across_clips: no GPU adapter");
|
||||
return;
|
||||
}
|
||||
let red = clip_path("across_red");
|
||||
let blue = clip_path("across_blue");
|
||||
oak_codec::testmedia::write_test_clip_solid(&red, 64, 64, 10, 10, [0.9, 0.1, 0.1, 1.0])
|
||||
.expect("red clip generation");
|
||||
oak_codec::testmedia::write_test_clip_solid(&blue, 64, 64, 10, 10, [0.1, 0.1, 0.9, 1.0])
|
||||
.expect("blue clip generation");
|
||||
|
||||
let red_path = red.to_string_lossy().to_string();
|
||||
let blue_path = blue.to_string_lossy().to_string();
|
||||
let clips: Vec<(&str, Rational, Rational)> = vec![
|
||||
(&red_path, Rational::new(0, 1), Rational::new(1, 2)),
|
||||
(&blue_path, Rational::new(1, 2), Rational::new(1, 1)),
|
||||
];
|
||||
let plain = build_project(&clips, None);
|
||||
let adjusted = build_project(
|
||||
&clips,
|
||||
Some((Rational::new(1, 4), Rational::new(3, 4), Some(0.5))),
|
||||
);
|
||||
let nodes_before = adjusted.0.lock().unwrap().graph.node_count();
|
||||
|
||||
// Inside the span, a point covered by each clip in turn.
|
||||
for (time, label) in [
|
||||
(Rational::new(1, 4), "the red clip"),
|
||||
(Rational::new(5, 8), "the blue clip"),
|
||||
] {
|
||||
let base = render_frame(&plain.0, plain.1, time);
|
||||
let swept = render_frame(&adjusted.0, adjusted.1, time);
|
||||
|
||||
// Sample away from the x=32 half boundary (MPEG-2 chroma bleed and
|
||||
// luma ringing stay within a few pixels of it).
|
||||
let mut ratios: Vec<f32> = Vec::new();
|
||||
let mut alphas: Vec<f32> = Vec::new();
|
||||
for y in 4..60 {
|
||||
for x in (4..24).chain(40..60) {
|
||||
for c in 0..3 {
|
||||
let a = channel(&base, x, y, c);
|
||||
if a > 0.02 {
|
||||
ratios.push(channel(&swept, x, y, c) / a);
|
||||
}
|
||||
}
|
||||
alphas.push(channel(&swept, x, y, 3));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
ratios.len() >= 512,
|
||||
"{label}: too few comparable samples: {}",
|
||||
ratios.len()
|
||||
);
|
||||
let mean = ratios.iter().sum::<f32>() / ratios.len() as f32;
|
||||
assert!(
|
||||
(mean - 0.25).abs() < 0.02,
|
||||
"{label}: a 0.5-opacity layer must leave 0.25 of each channel (shader x0.5, alpha-over x0.5), got {mean}"
|
||||
);
|
||||
let mean_alpha = alphas.iter().sum::<f32>() / alphas.len() as f32;
|
||||
assert!(
|
||||
(mean_alpha - 0.5).abs() < 0.1,
|
||||
"{label}: the swept frame's alpha must be 0.5, got {mean_alpha}"
|
||||
);
|
||||
}
|
||||
|
||||
// Outside the span, nothing changes — not even a rounding step.
|
||||
for time in [Rational::new(1, 8), Rational::new(7, 8)] {
|
||||
assert_eq!(
|
||||
render_frame(&plain.0, plain.1, time),
|
||||
render_frame(&adjusted.0, adjusted.1, time),
|
||||
"t={time:?} is outside the layer's span and must render unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
adjusted.0.lock().unwrap().graph.node_count(),
|
||||
nodes_before,
|
||||
"the sweep must tear its temporary source node back down"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&red);
|
||||
let _ = std::fs::remove_file(&blue);
|
||||
}
|
||||
|
||||
/// A bare adjustment layer (a block with no effect chain) is inert: there
|
||||
/// is no chain head to feed, so the tracks underneath render byte for byte
|
||||
/// as if the block were absent, and the block leaves no node behind.
|
||||
#[test]
|
||||
fn bare_adjustment_layer_passes_lower_tracks_through() {
|
||||
let red = clip_path("bare_red");
|
||||
oak_codec::testmedia::write_test_clip_solid(&red, 64, 64, 10, 10, [0.9, 0.1, 0.1, 1.0])
|
||||
.expect("red clip generation");
|
||||
let red_path = red.to_string_lossy().to_string();
|
||||
let clips: Vec<(&str, Rational, Rational)> =
|
||||
vec![(&red_path, Rational::new(0, 1), Rational::new(1, 1))];
|
||||
|
||||
let plain = build_project(&clips, None);
|
||||
let bare = build_project(
|
||||
&clips,
|
||||
Some((Rational::new(1, 4), Rational::new(3, 4), None)),
|
||||
);
|
||||
let nodes_before = bare.0.lock().unwrap().graph.node_count();
|
||||
|
||||
let time = Rational::new(1, 2);
|
||||
let base = render_frame(&plain.0, plain.1, time);
|
||||
assert!(
|
||||
base.iter().any(|&b| b != 0),
|
||||
"the reference frame is not black"
|
||||
);
|
||||
assert_eq!(
|
||||
render_frame(&bare.0, bare.1, time),
|
||||
base,
|
||||
"a bare adjustment layer must not change the render"
|
||||
);
|
||||
assert_eq!(
|
||||
bare.0.lock().unwrap().graph.node_count(),
|
||||
nodes_before,
|
||||
"a bare adjustment layer must not grow the graph"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&red);
|
||||
}
|
||||
@@ -67,6 +67,7 @@ fn params(time: Rational) -> VideoTicketParams {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Timeline transitions on the graph render path: an enabled transition
|
||||
//! block covering the request time replaces the plain clip read with a
|
||||
//! blend of the two clips it joins, driven by the block's style combo and
|
||||
//! its position across the block's own span. Outside the span the render
|
||||
//! is byte-identical to the same project without the transition.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use oak_core::texture::Texture;
|
||||
use oak_core::{PixelFormat, Rational, TimeRange};
|
||||
use oak_node::block::{ClipBlockBehavior, TransitionBlockBehavior};
|
||||
use oak_node::footage::FootageBehavior;
|
||||
use oak_node::id::NodeId;
|
||||
use oak_node::node::NodeCore;
|
||||
use oak_node::project::Project;
|
||||
use oak_node::sequence::SequenceBehavior;
|
||||
use oak_node::track::{TrackBehavior, TrackListBehavior};
|
||||
|
||||
mod common;
|
||||
|
||||
/// Unique temp path per test (the process id disambiguates parallel test
|
||||
/// binaries; the tag separates tests inside one binary).
|
||||
fn clip_path(tag: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("oakrender_trans_{tag}_{}.mp4", std::process::id()))
|
||||
}
|
||||
|
||||
/// The transition mechanics are color-space independent, so pin the
|
||||
/// working space to the legacy sRGB pass-through like the adjustment
|
||||
/// layer tests do and compare decoded pixel values directly.
|
||||
fn pin_legacy_working_space() {
|
||||
oak_core::color::set_pipeline_color_settings(
|
||||
oak_core::colormath::WorkingColorSpace::SrgbLegacy,
|
||||
oak_core::colormath::OutputColorSpec::default(),
|
||||
);
|
||||
}
|
||||
|
||||
/// V1 (a single track carrying `clips`, in order) plus an optional
|
||||
/// transition block. `transition` is `(seam, in_offset, out_offset)`: the
|
||||
/// block is created with `[seam - in_offset, seam + out_offset]` and
|
||||
/// inserted on the track between the first two clips, its two inputs
|
||||
/// wired to them (`out_block_in` from the first clip, `in_block_in` from
|
||||
/// the second). Returns the transition block's id so a test can flip its
|
||||
/// style combo.
|
||||
fn build_project(
|
||||
clips: &[(&str, Rational, Rational)],
|
||||
transition: Option<(Rational, Rational, Rational)>,
|
||||
) -> (Arc<Mutex<Project>>, NodeId, Option<NodeId>) {
|
||||
pin_legacy_working_space();
|
||||
let project = Project::new();
|
||||
let mut transition_id = None;
|
||||
let seq;
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
let (score, sbehavior) = SequenceBehavior::create();
|
||||
seq = p.graph.add_node(score, sbehavior);
|
||||
|
||||
let (tcore, tbehavior) = TrackListBehavior::create();
|
||||
let tl = p.graph.add_node(tcore, tbehavior);
|
||||
|
||||
let (tcore, tbehavior) = TrackBehavior::create();
|
||||
let v1 = p.graph.add_node(tcore, tbehavior);
|
||||
|
||||
let mut clip_ids: Vec<NodeId> = Vec::new();
|
||||
for &(path, in_, out) in clips {
|
||||
let mut footage = FootageBehavior::new(path);
|
||||
footage.probe().expect("probe the generated clip");
|
||||
let footage = p.graph.add_node(NodeCore::new(), Box::new(footage));
|
||||
|
||||
let (ccore, cbehavior) = oak_node::block::clip_create();
|
||||
let clip = p.graph.add_node(ccore, cbehavior);
|
||||
p.graph
|
||||
.connect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
|
||||
.expect("connect footage to clip");
|
||||
|
||||
p.graph
|
||||
.get_mut(clip)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<ClipBlockBehavior>()
|
||||
.expect("clip block")
|
||||
.core
|
||||
.range = TimeRange::new(in_, out);
|
||||
|
||||
clip_ids.push(clip);
|
||||
}
|
||||
|
||||
if let Some((seam, in_offset, out_offset)) = transition {
|
||||
let (tcore, tbehavior) = oak_node::block::transition_create();
|
||||
let block = p.graph.add_node(tcore, tbehavior);
|
||||
p.graph
|
||||
.get_mut(block)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TransitionBlockBehavior>()
|
||||
.expect("transition block")
|
||||
.core
|
||||
.range = TimeRange::new(seam - in_offset, seam + out_offset);
|
||||
{
|
||||
let behavior = p
|
||||
.graph
|
||||
.get_mut(block)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TransitionBlockBehavior>()
|
||||
.expect("transition block");
|
||||
behavior.in_offset = in_offset;
|
||||
behavior.out_offset = out_offset;
|
||||
}
|
||||
p.graph
|
||||
.connect(
|
||||
clip_ids[0],
|
||||
block,
|
||||
oak_node::block::transition_input::OUT_BLOCK,
|
||||
-1,
|
||||
)
|
||||
.expect("connect the outgoing clip to the transition");
|
||||
p.graph
|
||||
.connect(
|
||||
clip_ids[1],
|
||||
block,
|
||||
oak_node::block::transition_input::IN_BLOCK,
|
||||
-1,
|
||||
)
|
||||
.expect("connect the incoming clip to the transition");
|
||||
transition_id = Some(block);
|
||||
|
||||
// Track order: the outgoing clip, the transition, the incoming
|
||||
// clip — the order `transition_commands` builds.
|
||||
let track = p
|
||||
.graph
|
||||
.get_mut(v1)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TrackBehavior>()
|
||||
.expect("video track");
|
||||
track.append_block(clip_ids[0]);
|
||||
track.append_block(block);
|
||||
track.append_block(clip_ids[1]);
|
||||
} else {
|
||||
let track = p
|
||||
.graph
|
||||
.get_mut(v1)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TrackBehavior>()
|
||||
.expect("video track");
|
||||
for &clip in &clip_ids {
|
||||
track.append_block(clip);
|
||||
}
|
||||
}
|
||||
|
||||
p.graph
|
||||
.get_mut(tl)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<TrackListBehavior>()
|
||||
.expect("video track list")
|
||||
.tracks
|
||||
.push(v1);
|
||||
|
||||
p.graph
|
||||
.get_mut(seq)
|
||||
.unwrap()
|
||||
.behavior
|
||||
.as_any_mut()
|
||||
.unwrap()
|
||||
.downcast_mut::<SequenceBehavior>()
|
||||
.expect("sequence")
|
||||
.track_lists
|
||||
.push(tl);
|
||||
}
|
||||
(project, seq, transition_id)
|
||||
}
|
||||
|
||||
/// Flip the transition block's style combo (index into
|
||||
/// `oak_node::nodes::transitions::TYPE_NAMES`).
|
||||
fn set_style(project: &Arc<Mutex<Project>>, block: NodeId, style: i64) {
|
||||
project
|
||||
.lock()
|
||||
.unwrap()
|
||||
.graph
|
||||
.get_mut(block)
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value(
|
||||
oak_node::block::transition_input::TYPE_INPUT,
|
||||
-1,
|
||||
oak_node::value::NodeValue::Combo(style),
|
||||
);
|
||||
}
|
||||
|
||||
/// The raw CPU frame bytes of a rendered texture.
|
||||
fn frame_data(texture: &Texture) -> &[u8] {
|
||||
let Texture::Cpu(frame) = texture else {
|
||||
panic!("graph render produced a non-CPU texture");
|
||||
};
|
||||
&frame.data
|
||||
}
|
||||
|
||||
/// Render one 64x64 F32 frame of `seq` at `time` and return its bytes.
|
||||
fn render_frame(project: &Arc<Mutex<Project>>, seq: NodeId, time: Rational) -> Vec<u8> {
|
||||
let texture =
|
||||
oak_render::eval::render_graph_frame(project, seq, time, (64, 64), PixelFormat::F32)
|
||||
.expect("graph render");
|
||||
frame_data(&texture).to_vec()
|
||||
}
|
||||
|
||||
/// The F32 RGBA channel of a 64x64 frame at `(x, y)`.
|
||||
fn channel(data: &[u8], x: usize, y: usize, c: usize) -> f32 {
|
||||
let off = (y * 64 + x) * 16 + c * 4;
|
||||
f32::from_le_bytes(data[off..off + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// The mean of channel `c` over `xs` x `ys` (8..56 both ways: away from
|
||||
/// the encoder's frame borders).
|
||||
fn channel_mean(data: &[u8], c: usize, xs: &[usize], ys: &[usize]) -> f32 {
|
||||
let mut sum = 0.0;
|
||||
let mut n = 0;
|
||||
for &y in ys {
|
||||
for &x in xs {
|
||||
sum += channel(data, x, y, c);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
sum / n as f32
|
||||
}
|
||||
|
||||
/// Two solid clips abut at t=1/2; a transition spanning [1/4, 3/4] mixes
|
||||
/// them across the cut. At the seam (progress 0.5) a cross dissolve must
|
||||
/// show the average of the two sides, while outside the span the render
|
||||
/// is byte-identical to the same project without the transition.
|
||||
///
|
||||
/// Skipped (with a note) when no GPU adapter exists.
|
||||
#[test]
|
||||
fn cross_dissolve_blends_the_two_sides_of_a_cut() {
|
||||
if oak_core::backend::GpuContext::shared().is_none() {
|
||||
eprintln!("skipping cross_dissolve_blends_the_two_sides_of_a_cut: no GPU adapter");
|
||||
return;
|
||||
}
|
||||
let red = clip_path("dissolve_red");
|
||||
let blue = clip_path("dissolve_blue");
|
||||
oak_codec::testmedia::write_test_clip_solid(&red, 64, 64, 10, 10, [0.9, 0.1, 0.1, 1.0])
|
||||
.expect("red clip generation");
|
||||
oak_codec::testmedia::write_test_clip_solid(&blue, 64, 64, 10, 10, [0.1, 0.1, 0.9, 1.0])
|
||||
.expect("blue clip generation");
|
||||
let red_path = red.to_string_lossy().to_string();
|
||||
let blue_path = blue.to_string_lossy().to_string();
|
||||
let clips: Vec<(&str, Rational, Rational)> = vec![
|
||||
(&red_path, Rational::new(0, 1), Rational::new(1, 2)),
|
||||
(&blue_path, Rational::new(1, 2), Rational::new(1, 1)),
|
||||
];
|
||||
let plain = build_project(&clips, None);
|
||||
let dissolved = build_project(
|
||||
&clips,
|
||||
Some((Rational::new(1, 2), Rational::new(1, 4), Rational::new(1, 4))),
|
||||
);
|
||||
let transition = dissolved.2.expect("the transition block");
|
||||
|
||||
// The two sides, rendered by the same project without the transition.
|
||||
let plain_red = render_frame(&plain.0, plain.1, Rational::new(1, 4));
|
||||
let plain_blue = render_frame(&plain.0, plain.1, Rational::new(5, 8));
|
||||
assert!(
|
||||
channel_mean(&plain_red, 0, &(8..56).collect::<Vec<_>>(), &(8..56).collect::<Vec<_>>()) > 0.5,
|
||||
"the outgoing reference must be the red clip"
|
||||
);
|
||||
assert!(
|
||||
channel_mean(&plain_blue, 0, &(8..56).collect::<Vec<_>>(), &(8..56).collect::<Vec<_>>()) < 0.5,
|
||||
"the incoming reference must be the blue clip"
|
||||
);
|
||||
|
||||
// At the exact seam the block is halfway through its span, so a cross
|
||||
// dissolve shows the average of the two sides. Sample away from the
|
||||
// frame borders (MPEG-2 chroma bleed lives at the edges).
|
||||
let seam = render_frame(&dissolved.0, dissolved.1, Rational::new(1, 2));
|
||||
let xs: Vec<usize> = (8..56).collect();
|
||||
let ys: Vec<usize> = (8..56).collect();
|
||||
for c in 0..3 {
|
||||
let expected = 0.5 * (channel_mean(&plain_red, c, &xs, &ys)
|
||||
+ channel_mean(&plain_blue, c, &xs, &ys));
|
||||
let got = channel_mean(&seam, c, &xs, &ys);
|
||||
assert!(
|
||||
(got - expected).abs() < 0.02,
|
||||
"channel {c} at the seam must be the average of the two sides: expected {expected}, got {got}"
|
||||
);
|
||||
}
|
||||
|
||||
// Outside the block's span the render is byte-identical to the plain
|
||||
// project: the transition is inert there.
|
||||
for time in [Rational::new(1, 8), Rational::new(7, 8)] {
|
||||
assert_eq!(
|
||||
render_frame(&dissolved.0, dissolved.1, time),
|
||||
render_frame(&plain.0, plain.1, time),
|
||||
"t={time:?} is outside the transition's span and must render unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
// The blend is the block's own output: the plain clip read is replaced,
|
||||
// not added under it. Drop the block's style back to a fade and the
|
||||
// seam goes through black (the fade's midpoint), proving the style
|
||||
// rides on this block.
|
||||
set_style(&dissolved.0, transition, 1);
|
||||
let fade = render_frame(&dissolved.0, dissolved.1, Rational::new(1, 2));
|
||||
assert!(
|
||||
channel_mean(&fade, 0, &xs, &ys) < 0.1 && channel_mean(&fade, 2, &xs, &ys) < 0.1,
|
||||
"a fade at progress 0.5 is fully in the fade color (black), got r={} b={}",
|
||||
channel_mean(&fade, 0, &xs, &ys),
|
||||
channel_mean(&fade, 2, &xs, &ys)
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&red);
|
||||
let _ = std::fs::remove_file(&blue);
|
||||
}
|
||||
|
||||
/// The block's style combo selects the shader: a wipe at progress 0.5
|
||||
/// puts the incoming clip on the left of the sweeping boundary and the
|
||||
/// outgoing one on the right (the outgoing image leads the sweep).
|
||||
#[test]
|
||||
fn wipe_style_splits_the_frame_at_the_boundary() {
|
||||
if oak_core::backend::GpuContext::shared().is_none() {
|
||||
eprintln!("skipping wipe_style_splits_the_frame_at_the_boundary: no GPU adapter");
|
||||
return;
|
||||
}
|
||||
let red = clip_path("wipe_red");
|
||||
let blue = clip_path("wipe_blue");
|
||||
oak_codec::testmedia::write_test_clip_solid(&red, 64, 64, 10, 10, [0.9, 0.1, 0.1, 1.0])
|
||||
.expect("red clip generation");
|
||||
oak_codec::testmedia::write_test_clip_solid(&blue, 64, 64, 10, 10, [0.1, 0.1, 0.9, 1.0])
|
||||
.expect("blue clip generation");
|
||||
let red_path = red.to_string_lossy().to_string();
|
||||
let blue_path = blue.to_string_lossy().to_string();
|
||||
let clips: Vec<(&str, Rational, Rational)> = vec![
|
||||
(&red_path, Rational::new(0, 1), Rational::new(1, 2)),
|
||||
(&blue_path, Rational::new(1, 2), Rational::new(1, 1)),
|
||||
];
|
||||
let project = build_project(
|
||||
&clips,
|
||||
Some((Rational::new(1, 2), Rational::new(1, 4), Rational::new(1, 4))),
|
||||
);
|
||||
set_style(&project.0, project.2.expect("the transition block"), 2);
|
||||
let wipe = render_frame(&project.0, project.1, Rational::new(1, 2));
|
||||
|
||||
// Left of the boundary (x=32 at progress 0.5) trails the sweep and
|
||||
// shows the incoming (blue) clip; right of it the outgoing (red) one.
|
||||
// Keep 4 px clear of the boundary's soft edge (`soft = 0.02` of the
|
||||
// width). Green is 0.1 in both clips, so only the red and blue
|
||||
// channels tell the two sides apart.
|
||||
let left = (8..28).collect::<Vec<_>>();
|
||||
let right = (36..56).collect::<Vec<_>>();
|
||||
let ys: Vec<usize> = (8..56).collect();
|
||||
let (l_red, r_red) = (
|
||||
channel_mean(&wipe, 0, &left, &ys),
|
||||
channel_mean(&wipe, 0, &right, &ys),
|
||||
);
|
||||
let (l_blue, r_blue) = (
|
||||
channel_mean(&wipe, 2, &left, &ys),
|
||||
channel_mean(&wipe, 2, &right, &ys),
|
||||
);
|
||||
assert!(
|
||||
l_red < 0.5 && l_blue > 0.5,
|
||||
"left of the boundary must be the incoming clip (blue): r={l_red} b={l_blue}"
|
||||
);
|
||||
assert!(
|
||||
r_red > 0.5 && r_blue < 0.5,
|
||||
"right of the boundary must be the outgoing clip (red): r={r_red} b={r_blue}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&red);
|
||||
let _ = std::fs::remove_file(&blue);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! GPU pixel tests for the layer-form transition node
|
||||
//! (`org.olivevideoeditor.Olive.transitionfx`): cross dissolve, fade, wipe
|
||||
//! and slide over `tex_in` + `blend_in`, with the progress either set on
|
||||
//! the row or auto-filled by the renderer from the adjustment layer's own
|
||||
//! span. Skips without a GPU adapter.
|
||||
|
||||
use oak_core::texture::Texture;
|
||||
use oak_core::{PixelFormat, Rational};
|
||||
use oak_node::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
|
||||
|
||||
const TRANSITIONFX: &str = "org.olivevideoeditor.Olive.transitionfx";
|
||||
|
||||
fn texture_value(t: Texture) -> NodeValue { NodeValue::Texture(oak_node::handle::make_owned(t)) }
|
||||
fn gpu() -> bool { oak_core::backend::GpuContext::shared().is_some() }
|
||||
fn filled_frame(size: (i32, i32), rgba: [f32; 4]) -> Texture {
|
||||
let mut f = oak_render::eval::generate_frame(Rational::new(0, 1), size, PixelFormat::F32).unwrap();
|
||||
for px in f.data.chunks_exact_mut(16) { for (c, v) in px.chunks_exact_mut(4).zip(rgba) { c.copy_from_slice(&v.to_le_bytes()); } }
|
||||
Texture::wrap_frame(f)
|
||||
}
|
||||
/// An 8x8 frame whose red channel carries each column's centre `u`
|
||||
/// (`(x + 0.5) / width`), so a sample taken at `u` reads back as exactly
|
||||
/// `u`: the slide tests use it to tell which part of the picture landed
|
||||
/// where.
|
||||
fn gradient_frame_x(size: (i32, i32)) -> Texture {
|
||||
let mut f = oak_render::eval::generate_frame(Rational::new(0, 1), size, PixelFormat::F32).unwrap();
|
||||
let w = size.0 as usize;
|
||||
for (i, px) in f.data.chunks_exact_mut(16).enumerate() {
|
||||
let u = ((i % w) as f32 + 0.5) / w as f32;
|
||||
for (c, v) in px.chunks_exact_mut(4).zip([u, 0.0, 0.0, 1.0]) { c.copy_from_slice(&v.to_le_bytes()); }
|
||||
}
|
||||
Texture::wrap_frame(f)
|
||||
}
|
||||
fn pixel_at(frame: &oak_core::texture::Frame, x: usize, y: usize) -> [f32; 4] {
|
||||
let stride = frame.linesize_bytes() as usize;
|
||||
let at = y * stride + x * 16;
|
||||
let mut out = [0f32; 4];
|
||||
for c in 0..4 { out[c] = f32::from_le_bytes(frame.data[at + c*4..at + c*4 + 4].try_into().unwrap()); }
|
||||
out
|
||||
}
|
||||
/// Evaluate one node row. `layer_progress` is the adjustment-layer sweep
|
||||
/// position the graph driver records before walking an effect chain; the
|
||||
/// shader job picks it up for `progress_in` when the node left the input
|
||||
/// out of its params (the auto-fill path).
|
||||
fn eval_node_row(
|
||||
type_id: &str,
|
||||
inputs: NodeValueRow,
|
||||
frame_size: Option<(i32, i32)>,
|
||||
layer_progress: Option<f64>,
|
||||
) -> oak_core::texture::Frame {
|
||||
use oak_node::traverser::RenderHooks;
|
||||
let (core, behavior) = oak_node::factory::Factory::global().create_any(type_id).expect("node type registered");
|
||||
let mut table = NodeValueTable::default();
|
||||
behavior.value(&core, &inputs, Rational::new(0, 1), &mut table);
|
||||
let mut hooks = oak_render::eval::RenderEvalHooks::new();
|
||||
hooks.frame_size = frame_size;
|
||||
hooks.layer_progress = layer_progress;
|
||||
hooks.resolve(oak_node::id::NodeId::INVALID, &inputs, &mut table);
|
||||
let Some(NodeValue::Texture(handle)) = table.get(ValueType::Texture) else { panic!("{type_id}: no texture produced") };
|
||||
if handle.ctx.is_null() { panic!("{type_id}: null texture produced"); }
|
||||
let tex = unsafe { oak_node::handle::get_checked::<Texture>(handle) }.expect("resolved texture");
|
||||
assert!(matches!(tex, Texture::Gpu { .. }), "{type_id}: must render on the GPU");
|
||||
tex.to_frame().expect("readback")
|
||||
}
|
||||
|
||||
/// Assert the four channels of a pixel against the expected values.
|
||||
fn assert_pixel(px: [f32; 4], want: [f32; 4], what: &str) {
|
||||
for c in 0..4 {
|
||||
assert!(
|
||||
(px[c] - want[c]).abs() < 1e-3,
|
||||
"{what}: channel {c}: got {px:?}, want {want:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The two-picture row the shader job carries: `tex_in` (From) and
|
||||
/// `blend_in` (To), each painted over the whole 8x8 frame.
|
||||
fn transition_row(tex: [f32; 4], blend: [f32; 4]) -> NodeValueRow {
|
||||
let mut row = NodeValueRow::new();
|
||||
row.insert("tex_in".to_string(), texture_value(filled_frame((8, 8), tex)));
|
||||
row.insert("blend_in".to_string(), texture_value(filled_frame((8, 8), blend)));
|
||||
row
|
||||
}
|
||||
|
||||
/// Style combo index for a row (cross dissolve is 0, the default).
|
||||
fn style(row: &mut NodeValueRow, index: i64) {
|
||||
row.insert("type_in".to_string(), NodeValue::Combo(index));
|
||||
}
|
||||
|
||||
/// Halfway cross dissolve lerps red and green to olive.
|
||||
#[test]
|
||||
fn crossdissolve_half_progress_blends() {
|
||||
if !gpu() { eprintln!("no adapter; skipping"); return; }
|
||||
let mut row = transition_row([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
|
||||
row.insert("progress_in".to_string(), NodeValue::Float(0.5));
|
||||
let frame = eval_node_row(TRANSITIONFX, row, Some((8, 8)), None);
|
||||
assert_pixel(pixel_at(&frame, 2, 2), [0.5, 0.5, 0.0, 1.0], "cross dissolve 0.5");
|
||||
}
|
||||
|
||||
/// Progress 0 and 1 are the two pictures untouched.
|
||||
#[test]
|
||||
fn crossdissolve_ends_are_the_inputs() {
|
||||
if !gpu() { eprintln!("no adapter; skipping"); return; }
|
||||
for (progress, want, what) in [
|
||||
(0.0, [1.0, 0.0, 0.0, 1.0], "cross dissolve 0"),
|
||||
(1.0, [0.0, 1.0, 0.0, 1.0], "cross dissolve 1"),
|
||||
] {
|
||||
let mut row = transition_row([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
|
||||
row.insert("progress_in".to_string(), NodeValue::Float(progress));
|
||||
let frame = eval_node_row(TRANSITIONFX, row, Some((8, 8)), None);
|
||||
assert_pixel(pixel_at(&frame, 4, 4), want, what);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fade mixes the picture up out of `color_in`: 0 is the colour, 1 the
|
||||
/// picture, and a blue `color_in` proves the uniform binds by name (the
|
||||
/// shader declares no `blend_in` at all). The row always carries the
|
||||
/// input's default, exactly as the traverser fills every input in — a
|
||||
/// row without `color_in` would leave the uniform zero-filled, which for
|
||||
/// a colour is transparent black rather than the input's opaque default.
|
||||
#[test]
|
||||
fn fade_dips_through_the_color() {
|
||||
if !gpu() { eprintln!("no adapter; skipping"); return; }
|
||||
let cases: [(f64, [f64; 4], [f32; 4]); 3] = [
|
||||
(0.0, [0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 1.0]),
|
||||
(0.5, [0.0, 0.0, 1.0, 1.0], [0.5, 0.0, 0.5, 1.0]),
|
||||
(1.0, [0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0]),
|
||||
];
|
||||
for (progress, color, want) in cases {
|
||||
let mut row = transition_row([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
|
||||
style(&mut row, 1);
|
||||
row.insert("progress_in".to_string(), NodeValue::Float(progress));
|
||||
row.insert("color_in".to_string(), NodeValue::Color(color));
|
||||
let frame = eval_node_row(TRANSITIONFX, row, Some((8, 8)), None);
|
||||
assert_pixel(pixel_at(&frame, 3, 5), want, "fade");
|
||||
}
|
||||
}
|
||||
|
||||
/// Wipe at half progress: the incoming picture covers the half the
|
||||
/// direction comes from, the outgoing keeps the rest. All four combo
|
||||
/// entries are the same path (axis and sign are two steps and two mixes).
|
||||
#[test]
|
||||
fn wipe_directions_place_the_incoming_picture() {
|
||||
if !gpu() { eprintln!("no adapter; skipping"); return; }
|
||||
let red = [1.0, 0.0, 0.0, 1.0];
|
||||
let green = [0.0, 1.0, 0.0, 1.0];
|
||||
// (direction, pixel, expected) — tex_in is red (From), blend_in green (To).
|
||||
let cases: [(i64, (usize, usize), [f32; 4]); 8] = [
|
||||
(0, (2, 4), green),
|
||||
(0, (6, 4), red),
|
||||
(1, (2, 4), red),
|
||||
(1, (6, 4), green),
|
||||
(2, (4, 2), green),
|
||||
(2, (4, 6), red),
|
||||
(3, (4, 2), red),
|
||||
(3, (4, 6), green),
|
||||
];
|
||||
for (direction, (x, y), want) in cases {
|
||||
let mut row = transition_row(red, green);
|
||||
style(&mut row, 2);
|
||||
row.insert("progress_in".to_string(), NodeValue::Float(0.5));
|
||||
row.insert("direction_in".to_string(), NodeValue::Combo(direction));
|
||||
let frame = eval_node_row(TRANSITIONFX, row, Some((8, 8)), None);
|
||||
assert_pixel(
|
||||
pixel_at(&frame, x, y),
|
||||
want,
|
||||
&format!("wipe direction {direction} at ({x},{y})"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Slide: a gradient stands in for the incoming picture, so the value at
|
||||
/// a pixel names the column that landed there. At progress 0.25 the
|
||||
/// incoming enters from the left with its trailing quarter on screen (the
|
||||
/// frame shows the gradient's `[0.75, 1]` columns), while the outgoing
|
||||
/// part shows the untouched `tex_in`. The reversed direction mirrors it.
|
||||
#[test]
|
||||
fn slide_moves_both_pictures_the_named_way() {
|
||||
if !gpu() { eprintln!("no adapter; skipping"); return; }
|
||||
let red = [1.0, 0.0, 0.0, 1.0];
|
||||
// (direction, pixel, expected red channel) at progress 0.25.
|
||||
let cases: [(i64, (usize, usize), f32); 4] = [
|
||||
(0, (0, 4), 0.8125),
|
||||
(0, (6, 4), 1.0),
|
||||
(1, (0, 4), 1.0),
|
||||
(1, (7, 4), 0.1875),
|
||||
];
|
||||
for (direction, (x, y), red_channel) in cases {
|
||||
let mut row = NodeValueRow::new();
|
||||
row.insert("tex_in".to_string(), texture_value(filled_frame((8, 8), red)));
|
||||
row.insert("blend_in".to_string(), texture_value(gradient_frame_x((8, 8))));
|
||||
style(&mut row, 3);
|
||||
row.insert("progress_in".to_string(), NodeValue::Float(0.25));
|
||||
row.insert("direction_in".to_string(), NodeValue::Combo(direction));
|
||||
let frame = eval_node_row(TRANSITIONFX, row, Some((8, 8)), None);
|
||||
assert_pixel(
|
||||
pixel_at(&frame, x, y),
|
||||
[red_channel, 0.0, 0.0, 1.0],
|
||||
&format!("slide direction {direction} at ({x},{y})"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-fill: the row carries no `progress_in` at all (the node dropped
|
||||
/// it), so the renderer inserts the adjustment layer's own progress. The
|
||||
/// same row renders differently for each hook value — without the fill the
|
||||
/// uniform would stay 0 and both calls would be pure `tex_in`.
|
||||
#[test]
|
||||
fn progress_is_filled_from_the_adjustment_layer() {
|
||||
if !gpu() { eprintln!("no adapter; skipping"); return; }
|
||||
let row = transition_row([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
|
||||
let frame = eval_node_row(TRANSITIONFX, row.clone(), Some((8, 8)), Some(0.5));
|
||||
assert_pixel(pixel_at(&frame, 4, 4), [0.5, 0.5, 0.0, 1.0], "auto progress 0.5");
|
||||
let frame = eval_node_row(TRANSITIONFX, row, Some((8, 8)), Some(0.75));
|
||||
assert_pixel(pixel_at(&frame, 4, 4), [0.25, 0.75, 0.0, 1.0], "auto progress 0.75");
|
||||
}
|
||||
|
||||
/// An explicit `progress_in` on the row wins over the layer sweep.
|
||||
#[test]
|
||||
fn explicit_progress_beats_the_layer_sweep() {
|
||||
if !gpu() { eprintln!("no adapter; skipping"); return; }
|
||||
let mut row = transition_row([1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]);
|
||||
row.insert("progress_in".to_string(), NodeValue::Float(0.25));
|
||||
let frame = eval_node_row(TRANSITIONFX, row, Some((8, 8)), Some(0.75));
|
||||
assert_pixel(pixel_at(&frame, 4, 4), [0.75, 0.25, 0.0, 1.0], "explicit progress");
|
||||
}
|
||||
@@ -31,7 +31,10 @@ use std::sync::{Arc, Mutex, MutexGuard};
|
||||
|
||||
use oak_core::videoparams::VideoParams as CommonVideoParams;
|
||||
use oak_core::Rational;
|
||||
use oak_node::block::{BlockCore, ClipBlockBehavior, GapBlockBehavior, TransitionBlockBehavior};
|
||||
use oak_node::block::{
|
||||
AdjustmentBlockBehavior, BlockCore, ClipBlockBehavior, GapBlockBehavior,
|
||||
TransitionBlockBehavior,
|
||||
};
|
||||
use oak_node::folder::FolderBehavior;
|
||||
use oak_node::footage::{FootageBehavior, StreamInfo};
|
||||
use oak_node::graph::Graph;
|
||||
@@ -108,6 +111,9 @@ pub fn block_core_of(graph: &Graph, block: NodeId) -> Option<BlockCore> {
|
||||
if let Some(b) = any.downcast_ref::<TransitionBlockBehavior>() {
|
||||
return Some(b.core.clone());
|
||||
}
|
||||
if let Some(b) = any.downcast_ref::<AdjustmentBlockBehavior>() {
|
||||
return Some(b.core.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -132,6 +138,10 @@ fn set_block_core(graph: &mut Graph, block: NodeId, f: impl FnOnce(&mut BlockCor
|
||||
f(&mut b.core);
|
||||
return true;
|
||||
}
|
||||
if let Some(b) = any.downcast_mut::<AdjustmentBlockBehavior>() {
|
||||
f(&mut b.core);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
@@ -728,6 +738,18 @@ pub fn block_kind(project: &ProjectRef, block: NodeId) -> BlockKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the block is an adjustment layer (neither `BlockKind` covers
|
||||
/// it: the enum mirrors the deleted C ABI's block types).
|
||||
pub fn block_is_adjustment(project: &ProjectRef, block: NodeId) -> bool {
|
||||
let guard = lock_project(project);
|
||||
guard
|
||||
.graph
|
||||
.get(block)
|
||||
.and_then(|e| e.behavior.as_any())
|
||||
.map(|a| a.downcast_ref::<AdjustmentBlockBehavior>().is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The block's timeline in point (`oaknode_block_get_in`).
|
||||
pub fn block_in(project: &ProjectRef, block: NodeId) -> Rational {
|
||||
let guard = lock_project(project);
|
||||
|
||||
@@ -143,8 +143,10 @@ impl SaveOTIOTask {
|
||||
}
|
||||
|
||||
/// Serialize one track: a `Track` whose kind matches the native track
|
||||
/// type, one OTIO block per native block, plus a trailing `Gap` when the
|
||||
/// track is shorter than `max_track_length`.
|
||||
/// type, one OTIO block per native block (an adjustment layer, which has
|
||||
/// no OTIO equivalent, becomes an equal-length `Gap` so later children
|
||||
/// keep their timeline positions), plus a trailing `Gap` when the track
|
||||
/// is shorter than `max_track_length`.
|
||||
///
|
||||
/// CPP-PARITY: saveotio.cpp (SaveOTIOTask::serialize_track)
|
||||
fn serialize_track(
|
||||
@@ -258,7 +260,28 @@ impl SaveOTIOTask {
|
||||
|
||||
Some(Composable::Transition(otio_transition))
|
||||
}
|
||||
nodeops::BlockKind::Other => None,
|
||||
nodeops::BlockKind::Other => {
|
||||
// Adjustment layers have no OTIO equivalent. OTIO places
|
||||
// children sequentially, so dropping the block would shift
|
||||
// every following child left; keeping its span as a Gap
|
||||
// holds the timeline in place (and round-trips as a gap,
|
||||
// which is what "no adjustment applied" means here).
|
||||
nodeops::block_is_adjustment(project, block).then(|| {
|
||||
Composable::Gap(Gap::new(
|
||||
TimeRange::new(
|
||||
RationalTime::from_rational(
|
||||
block_in_of(project, block),
|
||||
sequence_rate,
|
||||
),
|
||||
RationalTime::from_rational(
|
||||
block_length_of(project, block),
|
||||
sequence_rate,
|
||||
),
|
||||
),
|
||||
nodeops::node_label(project, block),
|
||||
))
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let Some(otio_block) = otio_block else {
|
||||
|
||||
@@ -542,6 +542,7 @@ impl RenderTask {
|
||||
cache_timebase: None,
|
||||
footage: Some((filename, 0)),
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
}),
|
||||
// Sequence viewer: the montage is resolved without the lock.
|
||||
None => {
|
||||
@@ -558,6 +559,7 @@ impl RenderTask {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage,
|
||||
adjustments: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
//! pointers (see `undocommon`). Detached blocks are held as owned arena
|
||||
//! entries on the command that detached them.
|
||||
|
||||
use oak_core::Rational;
|
||||
use oak_core::{Rational, TimeRange};
|
||||
use oak_node::block::transition_input::{IN_BLOCK, OUT_BLOCK};
|
||||
use oak_node::graph::NodeEntry;
|
||||
use oak_node::track::TrackType;
|
||||
use oak_undo::undocommand::UndoCommand;
|
||||
@@ -33,13 +34,15 @@ use oak_undo::undocommand::UndoCommand;
|
||||
use crate::undocommon::{box_command, create_block_remove_command, Command};
|
||||
use crate::undosplit::BlockSplitPreservingLinksCommand;
|
||||
use crate::util::{
|
||||
block_add_to_graph, block_enabled, block_gap_create, block_in, block_kind, block_length,
|
||||
block_next, block_out, block_previous, block_remove_from_graph, block_set_enabled,
|
||||
block_set_in, block_set_length_and_media_in, block_set_length_and_media_out, block_track,
|
||||
block_add_to_graph, block_connect, block_connected_input, block_disconnect_input,
|
||||
block_enabled, block_gap_create, block_in, block_kind, block_length, block_next, block_out,
|
||||
block_previous, block_range, block_remove_from_graph, block_set_enabled, block_set_in,
|
||||
block_set_length_and_media_in, block_set_length_and_media_out, block_set_range, block_track,
|
||||
clip_media_in, clip_set_media_in, same_block, track_append_block, track_create,
|
||||
track_insert_block_after, track_insert_block_before, track_replace_block,
|
||||
track_ripple_remove_block, tracklist_append, tracklist_remove, tracklist_remove_last,
|
||||
tracklist_track_at, tracklist_track_count, tracklist_type, BlockKind, NodeRef,
|
||||
tracklist_track_at, tracklist_track_count, tracklist_type, transition_offsets,
|
||||
transition_set_offsets, BlockKind, NodeRef,
|
||||
};
|
||||
|
||||
// `oaknode/sequence.h` element input ids (the C++ automerge branch).
|
||||
@@ -540,6 +543,104 @@ impl Command for TimelineRemoveTrackCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// `TransitionSetOffsetsCommand` — change a transition block's two wedge
|
||||
/// offsets, keeping the block pinned to the same seam.
|
||||
///
|
||||
/// The C++ timeline has no equivalent (a transition's offsets are written
|
||||
/// once, at creation); the Rust port needs one so the timeline's wedge drag
|
||||
/// (`TransitionChanged`) can resize a transition undoably. A transition spans
|
||||
/// `seam - in_offset .. seam + out_offset`, so the stored range follows the
|
||||
/// offsets — growing one wedge without moving the matching range end would
|
||||
/// drift the block off its seam.
|
||||
pub struct TransitionSetOffsetsCommand {
|
||||
/// Transition block to change.
|
||||
block: NodeRef,
|
||||
/// New in offset (`None` keeps the current one).
|
||||
new_in_offset: Option<Rational>,
|
||||
/// New out offset (`None` keeps the current one).
|
||||
new_out_offset: Option<Rational>,
|
||||
/// Whether the pre-change state has been captured.
|
||||
captured: bool,
|
||||
/// In offset captured at `redo` time, restored by `undo`.
|
||||
old_in_offset: Rational,
|
||||
/// Out offset captured at `redo` time, restored by `undo`.
|
||||
old_out_offset: Rational,
|
||||
/// Stored range captured at `redo` time, restored by `undo`.
|
||||
old_range: TimeRange,
|
||||
/// The seam the offsets straddle, captured at `redo` time.
|
||||
seam: Rational,
|
||||
}
|
||||
|
||||
impl TransitionSetOffsetsCommand {
|
||||
/// Construct from a transition block and the offsets to set (`None`
|
||||
/// leaves that side as it is).
|
||||
///
|
||||
/// New signature (single-lib): `pub fn new(block: NodeRef, new_in_offset: Option<Rational>, new_out_offset: Option<Rational>) -> TransitionSetOffsetsCommand`
|
||||
pub fn new(
|
||||
block: NodeRef,
|
||||
new_in_offset: Option<Rational>,
|
||||
new_out_offset: Option<Rational>,
|
||||
) -> Self {
|
||||
Self {
|
||||
block,
|
||||
new_in_offset,
|
||||
new_out_offset,
|
||||
captured: false,
|
||||
old_in_offset: Rational::new(0, 1),
|
||||
old_out_offset: Rational::new(0, 1),
|
||||
old_range: TimeRange::default(),
|
||||
seam: Rational::new(0, 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// `redo`: capture the offsets/range once, then write the new offsets
|
||||
/// and move the range with them.
|
||||
pub fn redo(&mut self) {
|
||||
if !self.captured {
|
||||
if let Some((in_offset, out_offset)) = transition_offsets(&self.block) {
|
||||
self.old_in_offset = in_offset;
|
||||
self.old_out_offset = out_offset;
|
||||
}
|
||||
if let Some(range) = block_range(&self.block) {
|
||||
self.old_range = range;
|
||||
}
|
||||
self.seam = block_in(&self.block) + self.old_in_offset;
|
||||
self.captured = true;
|
||||
}
|
||||
|
||||
let in_offset = self.new_in_offset.unwrap_or(self.old_in_offset);
|
||||
let out_offset = self.new_out_offset.unwrap_or(self.old_out_offset);
|
||||
transition_set_offsets(&self.block, in_offset, out_offset);
|
||||
block_set_range(
|
||||
&self.block,
|
||||
TimeRange::new(self.seam - in_offset, self.seam + out_offset),
|
||||
);
|
||||
}
|
||||
|
||||
/// `undo`: restore the captured offsets and range.
|
||||
pub fn undo(&mut self) {
|
||||
transition_set_offsets(&self.block, self.old_in_offset, self.old_out_offset);
|
||||
block_set_range(&self.block, self.old_range);
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo command value.
|
||||
pub fn to_command(self) -> UndoCommand {
|
||||
box_command(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Command for TransitionSetOffsetsCommand {
|
||||
/// `Command::redo` — the inherent method takes precedence.
|
||||
fn redo(&mut self) {
|
||||
self.redo();
|
||||
}
|
||||
|
||||
/// `Command::undo` — the inherent method takes precedence.
|
||||
fn undo(&mut self) {
|
||||
self.undo();
|
||||
}
|
||||
}
|
||||
|
||||
/// `TransitionRemoveCommand` — detach a transition block from its neighbours,
|
||||
/// optionally removing it from the whole graph (timelineundogeneral.h).
|
||||
pub struct TransitionRemoveCommand {
|
||||
@@ -553,6 +654,20 @@ pub struct TransitionRemoveCommand {
|
||||
out_block: Option<NodeRef>,
|
||||
/// Block before the transition.
|
||||
in_block: Option<NodeRef>,
|
||||
/// Whether the pre-removal state has been captured.
|
||||
captured: bool,
|
||||
/// In offset captured before removal, restored by `undo`.
|
||||
in_offset: Rational,
|
||||
/// Out offset captured before removal, restored by `undo`.
|
||||
out_offset: Rational,
|
||||
/// Stored range captured before removal, restored by `undo`.
|
||||
range: TimeRange,
|
||||
/// Node feeding the transition's `out_block_in` input (the outgoing
|
||||
/// clip), captured before removal and restored by `undo`.
|
||||
connected_out: Option<NodeRef>,
|
||||
/// Node feeding the transition's `in_block_in` input (the incoming
|
||||
/// clip), captured before removal and restored by `undo`.
|
||||
connected_in: Option<NodeRef>,
|
||||
/// Graph-removal command built at `redo` time.
|
||||
remove_command: Option<UndoCommand>,
|
||||
}
|
||||
@@ -568,19 +683,41 @@ impl TransitionRemoveCommand {
|
||||
track: None,
|
||||
out_block: None,
|
||||
in_block: None,
|
||||
captured: false,
|
||||
in_offset: Rational::new(0, 1),
|
||||
out_offset: Rational::new(0, 1),
|
||||
range: TimeRange::default(),
|
||||
connected_out: None,
|
||||
connected_in: None,
|
||||
remove_command: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `redo`: relink neighbours around the transition and remove it.
|
||||
/// `redo`: unlink the transition from its neighbours and remove it.
|
||||
pub fn redo(&mut self) {
|
||||
self.track = block_track(&self.block);
|
||||
self.out_block = block_next(&self.block);
|
||||
self.in_block = block_previous(&self.block);
|
||||
// NOTE: the C++ extends the neighbouring blocks by the transition
|
||||
// offsets and disconnects the transition's inputs; the Rust block
|
||||
// model has no transition edges, so only the ripple-remove is
|
||||
// performed.
|
||||
// The C++ also extends the neighbouring blocks by the transition's
|
||||
// offsets; the Rust blocks already meet at the seam once the
|
||||
// transition leaves the track (a transition's span overlaps its
|
||||
// neighbours instead of filling a gap of its own), so only the detach
|
||||
// is performed. The offsets, the stored range and both input edges
|
||||
// are captured first so `undo` can put the block back as it was.
|
||||
if !self.captured {
|
||||
if let Some((in_offset, out_offset)) = transition_offsets(&self.block) {
|
||||
self.in_offset = in_offset;
|
||||
self.out_offset = out_offset;
|
||||
}
|
||||
if let Some(range) = block_range(&self.block) {
|
||||
self.range = range;
|
||||
}
|
||||
self.connected_out = block_connected_input(&self.block, OUT_BLOCK);
|
||||
self.connected_in = block_connected_input(&self.block, IN_BLOCK);
|
||||
self.captured = true;
|
||||
}
|
||||
block_disconnect_input(&self.block, OUT_BLOCK);
|
||||
block_disconnect_input(&self.block, IN_BLOCK);
|
||||
if let Some(track) = &self.track {
|
||||
track_ripple_remove_block(track, &self.block);
|
||||
}
|
||||
@@ -594,23 +731,27 @@ impl TransitionRemoveCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/// `undo`: restore the transition between its neighbours.
|
||||
/// `undo`: restore the transition, its offsets and its two edges.
|
||||
pub fn undo(&mut self) {
|
||||
if self.remove_from_graph {
|
||||
if let Some(c) = self.remove_command.as_mut() {
|
||||
c.undo_now();
|
||||
}
|
||||
}
|
||||
// NOTE: the C++ re-inserts the transition between its former
|
||||
// neighbours, reconnects them and restores the offsets; the
|
||||
// re-insert is real, the offset/connection restoration is not
|
||||
// modelled (no transition edges in the Rust block model).
|
||||
if let Some(track) = &self.track {
|
||||
match &self.in_block {
|
||||
Some(before) => track_insert_block_after(track, &self.block, Some(before)),
|
||||
None => track_insert_block_after(track, &self.block, None),
|
||||
}
|
||||
}
|
||||
transition_set_offsets(&self.block, self.in_offset, self.out_offset);
|
||||
block_set_range(&self.block, self.range);
|
||||
if let Some(source) = &self.connected_out {
|
||||
block_connect(source, &self.block, OUT_BLOCK);
|
||||
}
|
||||
if let Some(source) = &self.connected_in {
|
||||
block_connect(source, &self.block, IN_BLOCK);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as an oakundo command value.
|
||||
@@ -831,9 +972,10 @@ impl TrackReplaceBlockWithGapCommand {
|
||||
}
|
||||
// NOTE: the C++ checks whether the transition is connected only to this
|
||||
// block (via `oaknode_transition_get_connected_out_block`/`_in_block` +
|
||||
// `same_block`) before adding a `TransitionRemoveCommand`; the Rust
|
||||
// block model has no transition edges, so a command removing the
|
||||
// transition outright is produced.
|
||||
// `same_block`) before adding a `TransitionRemoveCommand`; that guard is
|
||||
// still not ported (the inputs are reachable through
|
||||
// `util::block_connected_input`), so a command removing the transition
|
||||
// outright is produced.
|
||||
self.transition_remove_commands
|
||||
.push(TransitionRemoveCommand::new(relevant, true));
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
|
||||
use oak_core::Rational;
|
||||
use oak_node::block::{BlockCore, ClipBlockBehavior, GapBlockBehavior, TransitionBlockBehavior};
|
||||
use oak_node::block::{
|
||||
AdjustmentBlockBehavior, BlockCore, ClipBlockBehavior, GapBlockBehavior,
|
||||
TransitionBlockBehavior,
|
||||
};
|
||||
use oak_node::graph::{Graph, NodeEntry};
|
||||
use oak_node::id::NodeId;
|
||||
use oak_node::node::NodeCore;
|
||||
@@ -122,6 +125,8 @@ fn block_core_of(project: &Project, id: NodeId) -> Option<&BlockCore> {
|
||||
Some(&gap.core)
|
||||
} else if let Some(transition) = a.downcast_ref::<TransitionBlockBehavior>() {
|
||||
Some(&transition.core)
|
||||
} else if let Some(adjustment) = a.downcast_ref::<AdjustmentBlockBehavior>() {
|
||||
Some(&adjustment.core)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -143,6 +148,13 @@ fn block_core_of_mut(project: &mut Project, id: NodeId) -> Option<&mut BlockCore
|
||||
.expect("checked above")
|
||||
.core,
|
||||
)
|
||||
} else if a.is::<AdjustmentBlockBehavior>() {
|
||||
Some(
|
||||
&mut a
|
||||
.downcast_mut::<AdjustmentBlockBehavior>()
|
||||
.expect("checked above")
|
||||
.core,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -213,6 +225,8 @@ impl oak_node::track::BlockRange for GraphBlockRange<'_> {
|
||||
a.downcast_ref::<GapBlockBehavior>().map(|b| &b.core),
|
||||
a.downcast_ref::<TransitionBlockBehavior>()
|
||||
.map(|b| &b.core),
|
||||
a.downcast_ref::<AdjustmentBlockBehavior>()
|
||||
.map(|b| &b.core),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -233,6 +247,8 @@ impl oak_node::track::BlockRange for GraphBlockRange<'_> {
|
||||
a.downcast_ref::<GapBlockBehavior>().map(|b| &b.core),
|
||||
a.downcast_ref::<TransitionBlockBehavior>()
|
||||
.map(|b| &b.core),
|
||||
a.downcast_ref::<AdjustmentBlockBehavior>()
|
||||
.map(|b| &b.core),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -339,6 +355,76 @@ pub fn clip_set_media_in(b: &NodeRef, media_in: Rational) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The block's stored timeline range (`None` for a stale/non-block node).
|
||||
pub fn block_range(b: &NodeRef) -> Option<oak_core::TimeRange> {
|
||||
let p = b.lock();
|
||||
block_core_of(&p, b.id).map(|c| c.range)
|
||||
}
|
||||
|
||||
/// Set the block's stored timeline range (a no-op for a stale/non-block
|
||||
/// node). The range is written whole, so the caller can move the in/out
|
||||
/// pair together without a media-in side effect (unlike
|
||||
/// [`block_set_length_and_media_out`] / [`block_set_length_and_media_in`]).
|
||||
pub fn block_set_range(b: &NodeRef, range: oak_core::TimeRange) {
|
||||
let mut p = b.lock();
|
||||
if let Some(core) = block_core_of_mut(&mut p, b.id) {
|
||||
core.range = range;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transition blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The transition block's `(in_offset, out_offset)` — the two wedges that
|
||||
/// reach into its neighbours — or `None` for a non-transition node.
|
||||
pub fn transition_offsets(t: &NodeRef) -> Option<(Rational, Rational)> {
|
||||
let p = t.lock();
|
||||
let a = p.graph.get(t.id)?.behavior.as_any()?;
|
||||
let transition = a.downcast_ref::<TransitionBlockBehavior>()?;
|
||||
Some((transition.in_offset, transition.out_offset))
|
||||
}
|
||||
|
||||
/// Set the transition block's offsets (a no-op for a non-transition node).
|
||||
pub fn transition_set_offsets(t: &NodeRef, in_offset: Rational, out_offset: Rational) {
|
||||
let mut p = t.lock();
|
||||
if let Some(a) = p.graph.get_mut(t.id).and_then(|e| e.behavior.as_any_mut()) {
|
||||
if let Some(transition) = a.downcast_mut::<TransitionBlockBehavior>() {
|
||||
transition.in_offset = in_offset;
|
||||
transition.out_offset = out_offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph edges (the transition block's two sides)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The node feeding `b`'s `input` (the input's connected source), or `None`
|
||||
/// when the input is unconnected or the node is stale.
|
||||
pub fn block_connected_input(b: &NodeRef, input: &str) -> Option<NodeRef> {
|
||||
let p = b.lock();
|
||||
p.graph
|
||||
.connected_output(b.id, input, -1)
|
||||
.map(|id| NodeRef::new(b.project.clone(), id))
|
||||
}
|
||||
|
||||
/// Connect `from`'s output into `to`'s `input`. No undo row of its own: the
|
||||
/// calling command owns its undo/redo semantics.
|
||||
pub fn block_connect(from: &NodeRef, to: &NodeRef, input: &str) {
|
||||
let mut p = from.lock();
|
||||
let _ = p.graph.connect(from.id, to.id, input, -1);
|
||||
}
|
||||
|
||||
/// Disconnect `to`'s `input`, returning the node that fed it (`None` when
|
||||
/// nothing was connected).
|
||||
pub fn block_disconnect_input(to: &NodeRef, input: &str) -> Option<NodeRef> {
|
||||
let mut p = to.lock();
|
||||
p.graph
|
||||
.disconnect_input(to.id, input, -1)
|
||||
.map(|id| NodeRef::new(to.project.clone(), id))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Block navigation (track membership)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -25,22 +25,26 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use oak_core::{Rational, TimeRange};
|
||||
use oak_node::block::{ClipBlockBehavior, GapBlockBehavior};
|
||||
use oak_node::block::{
|
||||
transition_input::{IN_BLOCK, OUT_BLOCK},
|
||||
ClipBlockBehavior, GapBlockBehavior, TransitionBlockBehavior,
|
||||
};
|
||||
use oak_node::project::Project;
|
||||
use oak_node::sequence::SequenceBehavior;
|
||||
use oak_node::track::{TrackBehavior, TrackListBehavior, TrackType};
|
||||
use oak_timeline::undocommon::Command;
|
||||
use oak_timeline::undogeneral::{
|
||||
BlockResizeCommand, TimelineAddTrackCommand, TimelineRemoveTrackCommand,
|
||||
TrackReplaceBlockWithGapCommand,
|
||||
TrackReplaceBlockWithGapCommand, TransitionRemoveCommand, TransitionSetOffsetsCommand,
|
||||
};
|
||||
use oak_timeline::undopointer::TrackMoveBlockCommand;
|
||||
use oak_timeline::undoripple::TrackRippleRemoveAreaCommand;
|
||||
use oak_timeline::undosplit::BlockSplitCommand;
|
||||
use oak_timeline::util::{
|
||||
block_clip_create, block_gap_create, block_in, block_length, block_out, block_track,
|
||||
track_append_block, track_block_at, track_block_count, track_length, track_ripple_remove_block,
|
||||
tracklist_track_count, NodeRef,
|
||||
block_clip_create, block_connect, block_connected_input, block_gap_create, block_in,
|
||||
block_length, block_out, block_range, block_track, track_append_block, track_block_at,
|
||||
track_block_count, track_length, track_ripple_remove_block, tracklist_track_count,
|
||||
transition_offsets, transition_set_offsets, NodeRef,
|
||||
};
|
||||
|
||||
/// A project with one sequence owning one (video) track list.
|
||||
@@ -134,6 +138,32 @@ fn add_gap(track: &NodeRef, in_: Rational, out: Rational) -> NodeRef {
|
||||
gap
|
||||
}
|
||||
|
||||
/// Add a transition block spanning `[in, out)` with the given wedge
|
||||
/// offsets to `track`.
|
||||
fn add_transition(
|
||||
track: &NodeRef,
|
||||
in_: Rational,
|
||||
out: Rational,
|
||||
in_offset: Rational,
|
||||
out_offset: Rational,
|
||||
) -> NodeRef {
|
||||
let (core, behavior) = oak_node::block::transition_create();
|
||||
let transition = {
|
||||
let mut p = track.project.lock().unwrap();
|
||||
let id = p.graph.add_node(core, behavior);
|
||||
if let Some(a) = p.graph.get_mut(id).and_then(|e| e.behavior.as_any_mut()) {
|
||||
if let Some(t) = a.downcast_mut::<TransitionBlockBehavior>() {
|
||||
t.core.range = TimeRange::new(in_, out);
|
||||
t.in_offset = in_offset;
|
||||
t.out_offset = out_offset;
|
||||
}
|
||||
}
|
||||
NodeRef::new(track.project.clone(), id)
|
||||
};
|
||||
track_append_block(track, &transition);
|
||||
transition
|
||||
}
|
||||
|
||||
/// The block span of `block` (`None` for a stale node).
|
||||
fn span_of(block: &NodeRef) -> Option<(Rational, Rational)> {
|
||||
let p = block.project.lock().unwrap();
|
||||
@@ -480,3 +510,193 @@ fn insert_gaps_round_trip() {
|
||||
Some((Rational::new(0, 1), Rational::new(100, 1)))
|
||||
);
|
||||
}
|
||||
|
||||
/// `TransitionSetOffsetsCommand` rewrites the wedge offsets and moves the
|
||||
/// stored range with them so the block stays pinned to its seam; undo
|
||||
/// restores both.
|
||||
#[test]
|
||||
fn transition_set_offsets_round_trip() {
|
||||
let project = make_project();
|
||||
let (_seq, list) = sequence_and_list(&project);
|
||||
let track = TimelineAddTrackCommand::run_immediately(list);
|
||||
add_clip(&track, Rational::new(0, 1), Rational::new(50, 1));
|
||||
// The transition overlaps both neighbours around the seam at 50.
|
||||
let transition = add_transition(
|
||||
&track,
|
||||
Rational::new(40, 1),
|
||||
Rational::new(60, 1),
|
||||
Rational::new(10, 1),
|
||||
Rational::new(10, 1),
|
||||
);
|
||||
add_clip(&track, Rational::new(50, 1), Rational::new(100, 1));
|
||||
|
||||
assert_eq!(
|
||||
transition_offsets(&transition),
|
||||
Some((Rational::new(10, 1), Rational::new(10, 1)))
|
||||
);
|
||||
|
||||
// The helper writes both offsets directly.
|
||||
transition_set_offsets(&transition, Rational::new(12, 1), Rational::new(8, 1));
|
||||
assert_eq!(
|
||||
transition_offsets(&transition),
|
||||
Some((Rational::new(12, 1), Rational::new(8, 1)))
|
||||
);
|
||||
transition_set_offsets(&transition, Rational::new(10, 1), Rational::new(10, 1));
|
||||
|
||||
let mut cmd = TransitionSetOffsetsCommand::new(
|
||||
transition.clone(),
|
||||
Some(Rational::new(5, 1)),
|
||||
Some(Rational::new(20, 1)),
|
||||
);
|
||||
cmd.redo();
|
||||
assert_eq!(
|
||||
transition_offsets(&transition),
|
||||
Some((Rational::new(5, 1), Rational::new(20, 1)))
|
||||
);
|
||||
// Range follows the offsets: [seam - in, seam + out), seam = 40 + 10.
|
||||
assert_eq!(
|
||||
block_range(&transition),
|
||||
Some(TimeRange::new(Rational::new(45, 1), Rational::new(70, 1)))
|
||||
);
|
||||
|
||||
cmd.undo();
|
||||
assert_eq!(
|
||||
transition_offsets(&transition),
|
||||
Some((Rational::new(10, 1), Rational::new(10, 1)))
|
||||
);
|
||||
assert_eq!(
|
||||
block_range(&transition),
|
||||
Some(TimeRange::new(Rational::new(40, 1), Rational::new(60, 1)))
|
||||
);
|
||||
|
||||
// `None` keeps the offset on that side.
|
||||
let mut partial =
|
||||
TransitionSetOffsetsCommand::new(transition.clone(), Some(Rational::new(7, 1)), None);
|
||||
partial.redo();
|
||||
assert_eq!(
|
||||
transition_offsets(&transition),
|
||||
Some((Rational::new(7, 1), Rational::new(10, 1)))
|
||||
);
|
||||
assert_eq!(
|
||||
block_range(&transition),
|
||||
Some(TimeRange::new(Rational::new(43, 1), Rational::new(60, 1)))
|
||||
);
|
||||
|
||||
partial.undo();
|
||||
assert_eq!(
|
||||
transition_offsets(&transition),
|
||||
Some((Rational::new(10, 1), Rational::new(10, 1)))
|
||||
);
|
||||
assert_eq!(
|
||||
block_range(&transition),
|
||||
Some(TimeRange::new(Rational::new(40, 1), Rational::new(60, 1)))
|
||||
);
|
||||
}
|
||||
|
||||
/// `TransitionRemoveCommand` unlinks the transition from its neighbours and
|
||||
/// its two node inputs; undo puts it back between them with the offsets,
|
||||
/// the range and both edges restored.
|
||||
#[test]
|
||||
fn transition_remove_restores_offsets_and_edges() {
|
||||
let project = make_project();
|
||||
let (_seq, list) = sequence_and_list(&project);
|
||||
let track = TimelineAddTrackCommand::run_immediately(list);
|
||||
let a = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1));
|
||||
let transition = add_transition(
|
||||
&track,
|
||||
Rational::new(40, 1),
|
||||
Rational::new(60, 1),
|
||||
Rational::new(10, 1),
|
||||
Rational::new(10, 1),
|
||||
);
|
||||
let b = add_clip(&track, Rational::new(50, 1), Rational::new(100, 1));
|
||||
block_connect(&a, &transition, OUT_BLOCK);
|
||||
block_connect(&b, &transition, IN_BLOCK);
|
||||
assert_eq!(
|
||||
block_connected_input(&transition, OUT_BLOCK).map(|n| n.id),
|
||||
Some(a.id)
|
||||
);
|
||||
assert_eq!(track_block_count(&track), 3);
|
||||
|
||||
let mut cmd = TransitionRemoveCommand::new(transition.clone(), false);
|
||||
cmd.redo();
|
||||
// The transition left the track and both clips remain; the edges are
|
||||
// gone too.
|
||||
assert_eq!(track_block_count(&track), 2);
|
||||
assert_eq!(track_block_at(&track, 0).unwrap().id, a.id);
|
||||
assert_eq!(track_block_at(&track, 1).unwrap().id, b.id);
|
||||
assert!(block_track(&transition).is_none());
|
||||
assert!(block_connected_input(&transition, OUT_BLOCK).is_none());
|
||||
assert!(block_connected_input(&transition, IN_BLOCK).is_none());
|
||||
|
||||
cmd.undo();
|
||||
assert_eq!(track_block_count(&track), 3);
|
||||
assert_eq!(track_block_at(&track, 0).unwrap().id, a.id);
|
||||
assert_eq!(track_block_at(&track, 1).unwrap().id, transition.id);
|
||||
assert_eq!(track_block_at(&track, 2).unwrap().id, b.id);
|
||||
assert_eq!(block_track(&transition).map(|t| t.id), Some(track.id));
|
||||
assert_eq!(
|
||||
transition_offsets(&transition),
|
||||
Some((Rational::new(10, 1), Rational::new(10, 1)))
|
||||
);
|
||||
assert_eq!(
|
||||
block_range(&transition),
|
||||
Some(TimeRange::new(Rational::new(40, 1), Rational::new(60, 1)))
|
||||
);
|
||||
assert_eq!(
|
||||
block_connected_input(&transition, OUT_BLOCK).map(|n| n.id),
|
||||
Some(a.id)
|
||||
);
|
||||
assert_eq!(
|
||||
block_connected_input(&transition, IN_BLOCK).map(|n| n.id),
|
||||
Some(b.id)
|
||||
);
|
||||
}
|
||||
|
||||
/// `TransitionRemoveCommand` with `remove_from_graph` detaches the node
|
||||
/// from the project graph as well; undo re-attaches it with the same
|
||||
/// identity, track slot, offsets and edges.
|
||||
#[test]
|
||||
fn transition_remove_from_graph_round_trip() {
|
||||
let project = make_project();
|
||||
let (_seq, list) = sequence_and_list(&project);
|
||||
let track = TimelineAddTrackCommand::run_immediately(list);
|
||||
let a = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1));
|
||||
let transition = add_transition(
|
||||
&track,
|
||||
Rational::new(40, 1),
|
||||
Rational::new(60, 1),
|
||||
Rational::new(10, 1),
|
||||
Rational::new(10, 1),
|
||||
);
|
||||
let b = add_clip(&track, Rational::new(50, 1), Rational::new(100, 1));
|
||||
block_connect(&a, &transition, OUT_BLOCK);
|
||||
block_connect(&b, &transition, IN_BLOCK);
|
||||
|
||||
let mut cmd = TransitionRemoveCommand::new(transition.clone(), true);
|
||||
cmd.redo();
|
||||
assert_eq!(track_block_count(&track), 2);
|
||||
assert!(block_track(&transition).is_none());
|
||||
assert!(project.lock().unwrap().graph.get(transition.id).is_none());
|
||||
|
||||
cmd.undo();
|
||||
assert!(project.lock().unwrap().graph.get(transition.id).is_some());
|
||||
assert_eq!(track_block_count(&track), 3);
|
||||
assert_eq!(track_block_at(&track, 1).unwrap().id, transition.id);
|
||||
assert_eq!(
|
||||
transition_offsets(&transition),
|
||||
Some((Rational::new(10, 1), Rational::new(10, 1)))
|
||||
);
|
||||
assert_eq!(
|
||||
block_range(&transition),
|
||||
Some(TimeRange::new(Rational::new(40, 1), Rational::new(60, 1)))
|
||||
);
|
||||
assert_eq!(
|
||||
block_connected_input(&transition, OUT_BLOCK).map(|n| n.id),
|
||||
Some(a.id)
|
||||
);
|
||||
assert_eq!(
|
||||
block_connected_input(&transition, IN_BLOCK).map(|n| n.id),
|
||||
Some(b.id)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ use serde_json::{json, Value};
|
||||
use oak_core::backend::{BackendKind, DisplayRenderer};
|
||||
use oak_core::{PixelFormat, Rational};
|
||||
use oak_render::eval;
|
||||
use oak_render::ticket::{AudioTicketParams, MontageClip, VideoTicketParams};
|
||||
use oak_render::ticket::{AdjustmentSpan, AudioTicketParams, MontageClip, VideoTicketParams};
|
||||
|
||||
use crate::framecache::FrameCache;
|
||||
use crate::ipc::{
|
||||
@@ -1204,6 +1204,11 @@ impl WorkerSession {
|
||||
effects: c.effects.iter().map(crate::ipc::montage_effect_from).collect(),
|
||||
})
|
||||
.collect();
|
||||
let adjustments: Vec<AdjustmentSpan> = spec
|
||||
.adjustments
|
||||
.iter()
|
||||
.map(oak_render::ipc::adjustment_from_wire)
|
||||
.collect();
|
||||
VideoTicketParams {
|
||||
viewer: if spec.viewer_node != 0 {
|
||||
spec.viewer_node
|
||||
@@ -1223,6 +1228,7 @@ impl WorkerSession {
|
||||
cache_timebase: None,
|
||||
footage,
|
||||
montage,
|
||||
adjustments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ fn params(time: Rational, footage: Option<(String, i32)>) -> Arc<VideoTicketPara
|
||||
cache_timebase: None,
|
||||
footage,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -537,6 +538,7 @@ fn submit_audio(
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
}),
|
||||
audio: Some(audio),
|
||||
// Never invoked on the process backend (the worker renders audio
|
||||
@@ -755,6 +757,7 @@ fn oversized_audio_ticket_is_refused_by_process_backend() {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
}),
|
||||
audio: Some(audio),
|
||||
produce: Arc::new(|_, _| {
|
||||
@@ -817,6 +820,7 @@ fn f32_ticket_gets_f32_slot_and_bgra8_stays_bgra8() {
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
}),
|
||||
audio: None,
|
||||
produce: Arc::new(|_, _| {
|
||||
@@ -961,6 +965,7 @@ fn post_graph_job(
|
||||
cache_timebase: None,
|
||||
footage: None,
|
||||
montage: Vec::new(),
|
||||
adjustments: Vec::new(),
|
||||
}),
|
||||
audio: None,
|
||||
produce: Arc::new(|_, _| {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# 调整图层与转场特效改造计划
|
||||
|
||||
> 面向实现者的任务书(2026-09-10)。本文只描述方案与工作项,不含已执行的代码修改。
|
||||
> 用户要求:"增加一项新功能:调整图层。可以将特效应用到多个 clip 那种。增加交叉叠化
|
||||
> 等几种常见的转场特效。我觉得转场特效可以做成应用在调整图层上的节点。注意转场开始
|
||||
> 和结束点,不能覆盖整个 clip,这要求调整图层也能针对 clip 的一段设置。"
|
||||
>
|
||||
> 配套调研:调整图层/转场基建审计(2026-09-10 会话,结论已并入本文各"现状"节)。
|
||||
|
||||
## 1. 需求分解
|
||||
|
||||
1. **调整图层(Adjustment Layer)**:时间轴上的一种块,位于某条视频轨、占一段时间
|
||||
范围,其效果链作用于**它下方所有视频轨在该时刻的合成结果**。一段范围可覆盖
|
||||
多个 clip("将特效应用到多个 clip"),也可只覆盖一个 clip 的一段("针对 clip
|
||||
的一段设置")。
|
||||
2. **转场特效**:交叉叠化(Cross Dissolve)等若干常见转场,有明确的**开始和结束
|
||||
点**(不能覆盖整个 clip)。
|
||||
3. 用户倾向的实现形态:转场做成**应用在调整图层上的节点**("我觉得转场特效可以
|
||||
做成应用在调整图层上的节点")——本文 §4 给出该形态的落地设计,同时指出其与
|
||||
一等转场块的关系。
|
||||
|
||||
## 2. 现状(关键事实,均带坐标)
|
||||
|
||||
### 2.1 转场块模型已存在,渲染/创建/UI 全空
|
||||
|
||||
- `crates/oak-node/src/block.rs:129-137`:`TransitionBlockBehavior { core: BlockCore,
|
||||
in_offset, out_offset }`,与普通块一样挂在 `TrackBehavior.blocks`。
|
||||
构造 `transition_create()`(block.rs:587-608)声明 `out_block_in`/`in_block_in`
|
||||
两个 NodeRef 输入(接前后块)。
|
||||
- `value()` 是空实现(block.rs:501-510,注释明确"渲染成一个洞");
|
||||
`render_graph_frame`(`crates/oak-render/src/eval.rs:1466-1472`)与
|
||||
`video_montage`(`renderops.rs:270`)都只认 `ClipBlockBehavior`,转场块被跳过。
|
||||
- 创建路径只有 OTIO 导入(`loadotio.rs:249-333`,含连线与 offset 设置);
|
||||
OTIO/FCPXML 导出已支持(saveotio.rs:250-259、fcpxml.rs:596-600)。
|
||||
- gpui 时间轴有转场楔形绘制与 `TransitionChanged` 事件(timeline/data.rs:122-135、
|
||||
clip.rs:346-377、timeline_view.rs:304-316),但 `RealClip` 不喂数据
|
||||
(`real.rs:873-897` 用默认 None)、`real.rs:4683` 明确丢弃该事件。
|
||||
- `oak-timeline` 有 `TransitionRemoveCommand`(undogeneral.rs:543-632,edge/offset
|
||||
恢复未建模)。行动注册表有 `Tool::Transition`/`DefaultTransition`(Ctrl+Shift+D)
|
||||
但无后端(actions.rs:131/229/733/778-782)。
|
||||
|
||||
### 2.2 调整图层:零代码,但模型/渲染/撤销的接入点都已确认
|
||||
|
||||
- 块基类 `BlockCore`(block.rs:28-113):range/media_in/speed/links/enabled/track。
|
||||
clip 的效果链挂载点 = `tex_in` 输入 + `core.effect_input`(block.rs:527-534),
|
||||
effectchain 的 insert/remove/move/set_enabled **与 clip 无关,任何有 effect
|
||||
input 的节点都能挂**。
|
||||
- 渲染主路径 `render_graph_frame`(eval.rs:1409-1546):逐 clip traverser 求值 →
|
||||
`composite_tracks` 自底向上 alpha-over。**调整图层的挂钩点 = eval.rs:1451-1478
|
||||
的轨道循环:遇到调整块时先把已收集的下层帧合成,跑调整块效果链,再往上。**
|
||||
- 备选渲染路径 montage:`VideoTicketParams.montage`(ticket.rs:67-83 的
|
||||
`MontageClip{effects: Vec<MontageEffect>}`),由 `renderops.rs:249-309
|
||||
video_montage` 构建。**两条渲染路径语义必须一致**(worker 快照图渲染优先、
|
||||
montage 兜底:worker.rs:1253-1299、manager.rs:119-151),调整图层两边都要实现。
|
||||
- 撤销:`graphops::place_footage_clip`(graphops.rs:1445-1506)=
|
||||
非撤销建节点 + 一条 undo 推 `TrackPlaceBlockCommand` + 连线;
|
||||
`TrackPlaceBlockCommand`(undopointer.rs:541-710)与块类型无关;
|
||||
graphops 的 clip 访问器(clip_ids/clip_range/trim_command 等)目前 downcast 到
|
||||
ClipBlockBehavior,需要推广到"任何带 BlockCore 的块"(参照
|
||||
`nodeops::block_core_of/set_block_core`,nodeops.rs:99-136 的模式)。
|
||||
- "给效果链喂一张外来纹理"目前**没有**现成 API:链首效果的 `tex_in` 未连接时
|
||||
取不到值(traverser.rs:247-251)。需要 §4.2 的"纹理源"机制。
|
||||
- UI:块绘制按 `ClipData.color/label` 即可区分外观(如需斜纹装饰才动
|
||||
gpui/crates/gpui/src/timeline/clip.rs);移动/修剪/分割/删除对画成 RealClip 的
|
||||
新块**免费**(只要 §2.2 的 graphops 访问器推广到位)。空区右键菜单在
|
||||
panels/timeline.rs:1268(`empty_area_menu`,知道轨道+帧)。
|
||||
|
||||
## 3. 设计 A:调整图层
|
||||
|
||||
### 3.1 节点模型
|
||||
|
||||
- 新行为 `AdjustmentBlockBehavior { core: BlockCore }`(oak-node/src/block.rs)+
|
||||
构造 `adjustment_create()`:只声明一个可连接、不可打关键帧的 `tex_in` 纹理输入,
|
||||
`core.effect_input = "tex_in"`;不需要 footage 链接与 media/speed/reverse 输入。
|
||||
在 `nodes/mod.rs` 注册表注册(type id `org.olivevideoeditor.Olive.adjustment`)。
|
||||
- 范围 = `core.range`(与其他块完全同构)——**天然满足"针对 clip 的一段"**:
|
||||
范围想拉多长拉多长,可跨多个 clip,可只盖一个 clip 的中段。
|
||||
- 效果链:与 clip 完全同构(effectchain 不变)。检查器选中调整块时,
|
||||
`selected_clip_node` 等 graphops 访问器推广到"任何带 BlockCore 的块"
|
||||
(详见 §3.4),效果栈/参数 UI 原样工作。
|
||||
- 序列化:`save_custom`/`load_custom` 走 block.rs:165-186 的扩展机制
|
||||
(旧读者跳过新元素,docs/project-storage.md 已为此预留);OTIO/FCPXML 导出
|
||||
第一版跳过调整块(记 TODO;OTIO 无调整图层概念,需要时映射为带效果的 Gap)。
|
||||
|
||||
### 3.2 渲染:图路径(render_graph_frame)
|
||||
|
||||
在 eval.rs 的轨道循环(1451-1478)中:
|
||||
|
||||
```
|
||||
对每条视频轨(顶层→底层顺序):
|
||||
若该轨在 time 有启用的调整块:
|
||||
1. 把已收集的下层 frames 先经 composite_tracks 合成出"下层合成图"
|
||||
2. 用 §3.3 的机制对下层合成图跑调整块的效果链 → 一张纹理
|
||||
3. 把该纹理作为唯一一帧压入 frames,继续向上收集
|
||||
否则: 照常收集该轨覆盖 time 的 clip
|
||||
最后: composite_tracks(frames)
|
||||
```
|
||||
|
||||
- 多个调整图层自然嵌套(每条含调整块的轨道触发一次上述 flush)。
|
||||
- 中间合成用现有 GPU 合成 helper(composite_tracks 的 GPU 变体),避免回读;
|
||||
调整链的输出再作为后续合成的输入纹理。
|
||||
- 调整块自己的 `value()`:直通 `tex_in`(与 clip 的直通同形);
|
||||
真正喂纹理由渲染驱动完成(§3.3)。
|
||||
|
||||
### 3.3 渲染:给效果链喂"下层合成图"(纹理源)
|
||||
|
||||
新增轻量"纹理源"节点(oak-node,`org.olivevideoeditor.Olive.composite_source`):
|
||||
`value()` 把渲染驱动预置的一张纹理推进输出表(形状仿 footage 节点的
|
||||
FootageJobPayload,但零开销直接装箱 `NodeValue::Texture`)。渲染驱动在每次
|
||||
flush 时:
|
||||
|
||||
1. 图内临时建(或复用一个每帧更新的)纹理源节点,预置下层合成图;
|
||||
2. 把它连到调整链首效果的 `tex_in`(临时边,求值后拆除,或每帧重连);
|
||||
3. `traverser.evaluate(EvalRequest::new(调整块, time))` —— 效果链、嵌套
|
||||
shader job、OFX 插件全部照常工作(无需任何特殊分支)。
|
||||
|
||||
(调研的备选方案——在 hooks 里替换链首 payload 的 tex_in 参数——魔法且脆,
|
||||
不采用;montage 机制(§3.4)覆盖不了内置 shader 特效,也不够。)
|
||||
|
||||
### 3.4 渲染:montage 路径(必须与图路径语义一致)
|
||||
|
||||
- `VideoTicketParams` 增加
|
||||
`adjustments: Vec<AdjustmentSpan { in_time, out_time, track_index, effects:
|
||||
Vec<MontageEffect> }>`;`video_montage` 识别调整块并用现成的 `clip_effects()`
|
||||
(renderops.rs:222-241,对任意宿主块可用)构建其效果快照。
|
||||
- `render_montage_frame_into` 在按轨道累积合成时,越过 `track_index` 边界就把
|
||||
对应 span 的 effects 用现有 `apply_clip_effects/apply_montage_effect`
|
||||
(eval.rs:1901-1999)应用到累积图上。
|
||||
- **已知局限(写进 TODO)**:montage 的效果执行目前只真支持 Opacity + OFX 插件,
|
||||
内置 shader 特效会 pass-through(既有问题,非本计划新增)。图路径(worker
|
||||
快照优先)始终正确;montage 兜底路径的保真度随 montage 效果执行器的
|
||||
补齐而提升——把"montage 效果执行器支持内置 shader 特效"列为后续独立工作项,
|
||||
不在本计划范围。
|
||||
|
||||
### 3.5 创建/编辑/UI/撤销
|
||||
|
||||
- 创建:时间轴空区右键菜单(panels/timeline.rs:1268 `empty_area_menu`)加
|
||||
"添加调整图层":以命中轨道+帧为锚,`adjustment_create()` 建节点 + 一条 undo 推
|
||||
`TrackPlaceBlockCommand`("Add Adjustment Layer",默认 5 秒或贴近吸附)。
|
||||
i18n 8 语言加 `timeline.context.add_adjustment_layer`。
|
||||
- 外观:RealClip 用区别于普通 clip 的颜色(引擎 `ClipData::color` 覆盖即可,
|
||||
不动 gpui);label 默认"调整图层"。
|
||||
- 交互:移动/修剪/分割/删除在 graphops 访问器推广后免费获得
|
||||
(clip_ids/clip_range/clip_track/trim_command/move_clip_to_track_commands/
|
||||
clip_block/delete_clip 全部改为认 BlockCore,而非只认 ClipBlockBehavior——
|
||||
参照 nodeops::block_core_of/set_block_core 的多 downcast 模式)。
|
||||
- 撤销:放置一条 undo(§2.2 模板);效果编辑走 effectchain 既有 undo;
|
||||
删除用 delete_clip 同款(TrackReplaceBlockWithGapCommand + remove_node_command)。
|
||||
|
||||
## 4. 设计 B:转场特效
|
||||
|
||||
### 4.1 转场块一等化(基础件)
|
||||
|
||||
转场块(TransitionBlockBehavior,§2.1)满足"开始和结束点不覆盖整个 clip"——
|
||||
它自身就是时间轴上一段独立范围的块,骑在两个 clip 的接缝上(in_offset 伸入
|
||||
前块尾部、out_offset 伸入后块头部)。
|
||||
|
||||
1. **创建**:两种方式都实现——
|
||||
a. 菜单/快捷键:`DefaultTransition`(Ctrl+Shift+D) 对选中 clip 的接缝插入
|
||||
默认转场(交叉叠化,默认 1 秒,可配置);clip 右键菜单加"添加转场"。
|
||||
创建 = transition_create() + 连线前后块 + 设置 offsets + 放置,一条 undo
|
||||
(连线模式照 loadotio.rs:307-333)。
|
||||
b.(后续)拖 clip 边缘产生重叠自动建转场——**不做**,本计划只做显式创建。
|
||||
2. **渲染(图路径)**:`render_graph_frame` 在逐 clip 求值阶段识别转场块覆盖
|
||||
的 time:对转场区间,同时求值前块与后块两张纹理,按转场类型混合后作为
|
||||
该区间输出(两输入混合的 shader 模式参照 merge.rs base/blend;混合 progress
|
||||
= (time − transition_in) / length)。**`TransitionBlockBehavior::value()`
|
||||
保持不被求值(渲染在合成驱动层完成)**,或按调研备选:把 value() 做成双输入
|
||||
shader job 的发出者(前后块纹理经 out_block_in/in_block_in 求值后注入)——
|
||||
实现时在两者中选改动更小者(倾向合成驱动层:与调整图层 flush 同层、共享
|
||||
中间合成设施)。
|
||||
3. **渲染(montage 路径)**:montage clip 列表按时间天然把前后 clip 都列出;
|
||||
在 `render_montage_frame_into` 对转场区间解码两张并按类型混合
|
||||
(与 §3.4 同一累积器阶段)。
|
||||
4. **转场类型(本批 4 种,均为内置 shader 节点实现,见 §4.2)**:
|
||||
交叉叠化(mix 渐变)、淡入淡出(对黑/对白,单边 dissolve)、
|
||||
划像(wipe,左/右/上/下方向+柔边)、推移(slide,推出/推入方向)。
|
||||
5. **UI**:`RealClip` 喂 `ClipData::in_transition/out_transition`
|
||||
(查块的前后 TransitionBlockBehavior 邻居,换算楔形长度);楔形拖拽
|
||||
`TransitionChanged` 在 real.rs:4683 落地(undoable 改 offsets,
|
||||
复用 transition_set_offsets_and_length 的 undo 模式)。
|
||||
转场块自身的移动/删除随 §3.5 的 graphops 推广免费获得;
|
||||
`TransitionRemoveCommand` 的 edge/offset 恢复缺陷(undogeneral.rs:580-583)
|
||||
顺手修掉(删除时记录并在 undo 恢复 offsets 与连线)。
|
||||
|
||||
### 4.2 转场 = 调整图层上的节点(用户提议的形态)
|
||||
|
||||
**输入问题的解法**:转场节点需要"另一幅画面"作第二输入。落地设计——
|
||||
|
||||
1. 转场节点(内置,`org.olivevideoeditor.Olive.transition.crossdissolve` 等,
|
||||
类别 `Category::Transition`)声明两个纹理输入:`tex_in`(链输入)与
|
||||
`blend_in`(第二输入,merge.rs 双输入绑定模式)。
|
||||
2. 叠加关系:调整图层盖在接缝上(范围=转场区间),其下层合成图在区间内已经
|
||||
包含后块(底层轨上有后块时)——节点混合的是:
|
||||
- `tex_in` = 下层合成图(当前时刻);
|
||||
- `blend_in` = 由图连接指定的参考源(默认:接缝前块的输出——创建转场时
|
||||
自动连好,节点编辑器里可见可改)。
|
||||
实际语义 = "下层画面 ⇄ 前块画面按 progress 混合",足以表达交叉叠化/
|
||||
划像/推移;淡入淡出不需要 blend_in(直接对黑场混合)。
|
||||
3. **progress 自动喂值**:渲染驱动在跑调整链时知道(layer_in, layer_out, time),
|
||||
把 `progress_in = (time − layer_in)/length` 像 `resolution_in` 一样自动填充
|
||||
(在 §3.3 的纹理源节点上顺带预置,或 hooks 增加 `layer_progress` 字段由
|
||||
process_shader_job 注入——实现时选改动小者,倾向后者:hooks 加
|
||||
`pub layer_progress: Option<f64>`,shader 声明 `uniform float progress_in`
|
||||
且 job 未显式提供时自动填入)。
|
||||
4. 这样用户获得两种用法:
|
||||
- 简单接缝转场 → §4.1 转场块(一键、楔形可见);
|
||||
- 自定义/复合转场 → 调整图层 + 转场节点(区间即调整块范围,
|
||||
"开始/结束点不覆盖整个 clip"由调整块 range 直接保证,节点编辑器里还能
|
||||
级联别的效果)。**两种用法渲染同一套 shader。**
|
||||
|
||||
### 4.3 验收语义(写进测试)
|
||||
|
||||
- 交叉叠化中点:两纯色输入输出各 50% 混合(GPU 像素测试)。
|
||||
- 转场区间外:画面与无转场完全一致(前后各采一帧断言)。
|
||||
- 调整图层跨 clip:下层两个不同色 clip,盖一个 Opacity=0.5 的调整层,
|
||||
区间两端的帧分别断言;区间外一帧断言不受影响。
|
||||
- 淡入淡出首尾:progress=0 全黑/全白,progress=1 原图。
|
||||
|
||||
## 5. 工作项(可分配给子代理的最小单元)
|
||||
|
||||
1. **W1 调整块模型+graphops 推广**:AdjustmentBlockBehavior/adjustment_create/
|
||||
注册/序列化;graphops 与 real.rs 的 clip 访问器推广到 BlockCore;
|
||||
selected_clip_node 命中调整块。单测:创建/修剪/删除 undo。
|
||||
2. **W2 调整图层渲染(图路径)**:eval.rs 轨道循环 flush + 纹理源节点 +
|
||||
hooks.layer_progress;GPU 测试(§4.3 的跨 clip 用例)。
|
||||
3. **W3 调整图层渲染(montage 路径)**:VideoTicketParams.adjustments +
|
||||
video_montage 构建 + render_montage_frame_into 应用 + 测试。
|
||||
4. **W4 调整图层 UI**:空区菜单创建、外观、i18n、检查器选中、app 层测试
|
||||
(mock+real)。
|
||||
5. **W5 转场块一等化**:创建(DefaultTransition+右键菜单)、图路径+montage
|
||||
渲染(4 类型 shader)、楔形喂数据+TransitionChanged 落地、
|
||||
TransitionRemoveCommand 缺陷修复、GPU/集成测试。
|
||||
6. **W6 转场节点(调整图层形态)**:transition.* 节点族(双输入+progress_in),
|
||||
自动连线(创建时把 blend_in 接到接缝前块)、节点编辑器可见性检查、
|
||||
GPU 测试(§4.3 中点/边界断言)。
|
||||
|
||||
依赖:W1→W2/W3→W4;W5 与 W1-W4 并行可做(只共享 §3.5 的 graphops 推广,
|
||||
排在 W1 后);W6 依赖 W2(layer_progress)与 W5(shader 共享)。
|
||||
建议 W1+W2 一个子代理、W3 一个、W4 一个、W5 一个、W6 一个,主代理统一审查。
|
||||
**冲突根禁令**:eval.rs 的 render_graph_frame 轨道循环只能由 W2 所有者改,
|
||||
W3/W5 需要同区改动时通过评审串行合入。
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
1. 空区右键能加调整图层;上面加 Blur/Opacity 后,其下方所有视频轨在层覆盖
|
||||
范围内的画面整体受影响;范围外不受影响;范围可只盖一个 clip 的一段。
|
||||
2. 选中接缝 Ctrl+Shift+D 插入 1 秒交叉叠化;播放/暂停/导出三种路径画面一致;
|
||||
楔形可见且可拖改长度;转场不覆盖整个 clip(区间外画面逐像素一致)。
|
||||
3. 调整图层上加交叉叠化节点,blend_in 接到前块后呈现同样叠化;
|
||||
progress 沿区间线性推进。
|
||||
4. 撤销/重做覆盖全部创建/编辑/删除路径。
|
||||
5. `cargo test --workspace` 全绿;新增 GPU 测试在无 GPU 环境跳过。
|
||||
Reference in New Issue
Block a user