2026-02-23 18:38:03 -06:00
class_name VideoPlayback
extends Control
## Video playback and seeking inside of Godot.
##
## To use this node, just add it anywhere and resize it to the desired size. Use the function [code]set_video_path(new_path)[/code] and the video will load. Take in mind that long video's can take a second or longer to load. If this is an issue you can preload the Video on startup of your project and set the video variable yourself, just remember to use the function [code]update_video()[/code] before the moment that you'd like to use it.
enum COLOR_PROFILE { AUTO , BT470 , BT601 , BT709 , BT2020 , BT2100 }
enum STREAM_TYPE { VIDEO = 0 , AUDIO = 1 , SUBTITLE = 2 }
signal frame_changed ( frame_nr : int ) ## Emitted when the current frame has changed, for showing and skipped frames.
signal next_frame_called ( frame_nr : int ) ## Emitted when a new frame is showing.
signal video_loaded ## Emitted when the video is ready for playback.
signal video_ended ## Emitted when the last frame has been shown.
signal playback_started ## Emitted when playback started/resumed.
signal playback_paused ## Emitted when playback is paused.
signal playback_ready ## Emitted when the node if fully setup and ready for playback.
const SHADER_PATH : String = " res://addons/gde_gozen/yuv_to_rgb.gdshader "
const PLAYBACK_SPEED_MIN : float = 0.25
const PLAYBACK_SPEED_MAX : float = 4
const AUDIO_OFFSET_THRESHOLD : float = 0.1
@ export_file var path : String = " " : set = set_video_path ## Full path to video file.
@ export var enable_audio : bool = true ## Enable/Disable audio playback. When setting this on false before loading the audio, the audio playback won't be loaded meaning that the video will load faster. If you want audio but only disable it at certain moments, switch this value to false *after* the video is loaded.
@ export var audio_speed_to_sync : bool = false ## Enable/Disable a slight audio playback speed increase/reduction when syncing audio and video to avoid a hard cut.
@ export var enable_auto_play : bool = false ## Enable/disable auto video playback.
@ export_range ( PLAYBACK_SPEED_MIN , PLAYBACK_SPEED_MAX , 0.05 )
var playback_speed : float = 1.0 : set = set_playback_speed ## Adjust the video playback speed, 0.5 = half the speed and 2 = double the speed.
@ export var pitch_adjust : bool = true : set = set_pitch_adjust ## When changing playback speed, do you want the pitch to change or stay the same?
@ export var loop : bool = false ## Enable/disable looping on video_ended.
@ export_group ( " Extra ' s " )
@ export var color_profile : COLOR_PROFILE = COLOR_PROFILE . AUTO : set = _set_color_profile ## Force a specific color profile if needed.
@ export var debug : bool = false ## Enable/disable the printing of debug info.
var video : GoZenVideo = null ## Video class object of GDE GoZen which interacts with video files through FFmpeg.
var video_texture : TextureRect = TextureRect . new ( ) ## The texture rect is the view of the video, you can adjust the scaling options as you like, it is set to always center and scale the image to fit within the main VideoPlayback node size.
var audio_player : AudioStreamPlayer = AudioStreamPlayer . new ( ) ## Audio player is the AudioStreamPlayer which handles the audio playback for the video, only mess with the settings if you know what you are doing and know what you'd like to achieve.
var is_playing : bool = false ## Bool to check if the video is currently playing or not.
var current_frame : int = 0 : set = _set_current_frame ## Current frame number which the video playback is at.
var video_streams : PackedInt32Array = [ ] ## List of video streams in the video file.
var audio_streams : PackedInt32Array = [ ] ## List of audio streams in the video file.
var subtitle_streams : PackedInt32Array = [ ] ## List of subtitle streams in the video file.
var chapters : Array [ Chapter ] = [ ] ## List of chapters in the video file.
var _time_elapsed : float = 0.
var _frame_time : float = 0
var _skips : int = 0
var _rotation : int = 0
var _padding : int = 0
var _frame_rate : float = 0.
var _frame_count : int = 0
var _has_alpha : bool = false
var _resolution : Vector2i = Vector2i . ZERO
var _shader_material : ShaderMaterial = null
var _video_thread : int = - 1
var _audio_pitch_effect : AudioEffectPitchShift = AudioEffectPitchShift . new ( )
var y_texture : ImageTexture
var u_texture : ImageTexture
var v_texture : ImageTexture
var a_texture : ImageTexture
#------------------------------------------------ TREE FUNCTIONS
func _enter_tree ( ) - > void :
var empty_image : Image = Image . create_empty ( 2 , 2 , false , Image . FORMAT_R8 )
y_texture = ImageTexture . create_from_image ( empty_image )
u_texture = ImageTexture . create_from_image ( empty_image )
v_texture = ImageTexture . create_from_image ( empty_image )
a_texture = ImageTexture . create_from_image ( empty_image )
_shader_material = ShaderMaterial . new ( )
_shader_material . shader = preload ( SHADER_PATH )
video_texture . material = _shader_material
video_texture . texture = ImageTexture . new ( )
video_texture . anchor_right = TextureRect . ANCHOR_END
video_texture . anchor_bottom = TextureRect . ANCHOR_END
video_texture . stretch_mode = TextureRect . STRETCH_KEEP_ASPECT_CENTERED
video_texture . expand_mode = TextureRect . EXPAND_IGNORE_SIZE
add_child ( video_texture )
add_child ( audio_player )
AudioServer . add_bus ( )
audio_player . bus = AudioServer . get_bus_name ( AudioServer . bus_count - 1 )
AudioServer . add_bus_effect ( AudioServer . bus_count - 1 , _audio_pitch_effect )
if debug and OS . get_name ( ) . to_lower ( ) != " web " :
_print_system_debug ( )
func _exit_tree ( ) - > void :
# Making certain no remaining tasks are running in separate threads.
if _video_thread != - 1 :
var error : int = WorkerThreadPool . wait_for_task_completion ( _video_thread )
if error != OK :
printerr ( " Something went wrong waiting for task completion! %s " % error )
_video_thread = - 1
if video != null :
close ( )
2026-03-02 01:59:33 -06:00
2026-02-23 18:38:03 -06:00
AudioServer . remove_bus ( AudioServer . get_bus_index ( audio_player . bus ) )
func _ready ( ) - > void :
playback_ready . emit ( )
#------------------------------------------------ VIDEO DATA HANDLING
## This is the starting point for video playback, provide a path of where
## the video file can be found and it will load a Video object. After which
## [code]_update_video()[/code] get's run and set's the first frame image.
func set_video_path ( new_path : String ) - > void :
if video != null :
close ( )
if ! is_node_ready ( ) :
await ready
if ! get_tree ( ) . root . is_node_ready ( ) :
await get_tree ( ) . root . ready
audio_player . stream = null # Cleaning up the stream just in case.
if new_path == " " or new_path . ends_with ( " .tscn " ) :
return
elif new_path . split ( " : " ) [ 0 ] == " uid " :
new_path = ResourceUID . get_id_path ( ResourceUID . text_to_id ( new_path ) )
path = new_path
video = GoZenVideo . new ( )
if debug :
video . enable_debug ( )
else :
video . disable_debug ( )
_video_thread = WorkerThreadPool . add_task ( _open_video )
if enable_audio :
_open_audio ( )
## Update the video manually by providing a GoZenVideo instance and an optional AudioStreamWAV.
2026-03-02 01:59:33 -06:00
func update_video ( video_instance : GoZenVideo , audio_stream : AudioStreamWAV = null ) - > void :
2026-02-23 18:38:03 -06:00
if video != null :
close ( )
2026-03-02 01:59:33 -06:00
path = video_instance . get_path ( )
2026-02-23 18:38:03 -06:00
_update_video ( video_instance )
2026-03-02 01:59:33 -06:00
if audio_stream :
audio_player . stream = audio_stream
else :
_open_audio ( )
2026-02-23 18:38:03 -06:00
2026-03-02 01:59:33 -06:00
## Only run this function after manually having added a Video object to the `video` variable. A good reason for doing this is to load your video's at startup time to prevent your program for freezing for a second when loading in big video files. Some video formats load faster then others so if you are experiencing issues with long loading times, try to use this function and create the video object on startup, or try switching the video format which you are using.
2026-02-23 18:38:03 -06:00
func _update_video ( new_video : GoZenVideo ) - > void :
video = new_video
if ! is_open ( ) :
printerr ( " Video isn ' t open! " )
return
var image : Image
var rotation_radians : float = deg_to_rad ( video . get_rotation ( ) )
is_playing = false
current_frame = 0
# Getting video data
_padding = video . get_padding ( )
_rotation = video . get_rotation ( )
_frame_rate = video . get_framerate ( )
_resolution = video . get_resolution ( )
_frame_count = video . get_frame_count ( )
_has_alpha = video . get_has_alpha ( )
video_streams = video . get_streams ( STREAM_TYPE . VIDEO )
audio_streams = video . get_streams ( STREAM_TYPE . AUDIO )
subtitle_streams = video . get_streams ( STREAM_TYPE . SUBTITLE )
2026-03-02 01:59:33 -06:00
2026-02-23 18:38:03 -06:00
chapters . clear ( )
for i : int in range ( video . get_chapter_count ( ) ) :
@ warning_ignore ( " UNSAFE_CALL_ARGUMENT " )
var chapter : Chapter = Chapter . new (
video . get_chapter_start ( i ) ,
video . get_chapter_end ( i ) ,
video . get_chapter_metadata ( i ) . get ( " title " , " " )
)
chapters . append ( chapter )
2026-03-02 01:59:33 -06:00
2026-02-23 18:38:03 -06:00
if abs ( _rotation ) == 90 :
image = Image . create_empty ( _resolution . y , _resolution . x , false , Image . FORMAT_R8 )
else :
image = Image . create_empty ( _resolution . x , _resolution . y , false , Image . FORMAT_R8 )
image . fill ( Color . WHITE )
if debug :
_print_video_debug ( )
@ warning_ignore ( " UNSAFE_METHOD_ACCESS " )
video_texture . texture . set_image ( image )
# Applying shader params.
_shader_material . set_shader_parameter ( " resolution " , video . get_actual_resolution ( ) )
_shader_material . set_shader_parameter ( " full_color " , video . is_full_color_range ( ) )
_shader_material . set_shader_parameter ( " interlaced " , video . get_interlaced ( ) )
_shader_material . set_shader_parameter ( " rotation " , rotation_radians )
_set_color_profile ( )
y_texture . set_image ( video . get_y_data ( ) )
u_texture . set_image ( video . get_u_data ( ) )
v_texture . set_image ( video . get_v_data ( ) )
a_texture . set_image ( video . get_a_data ( ) if _has_alpha else image )
_shader_material . set_shader_parameter ( " y_data " , y_texture )
_shader_material . set_shader_parameter ( " u_data " , u_texture )
_shader_material . set_shader_parameter ( " v_data " , v_texture )
_shader_material . set_shader_parameter ( " a_data " , a_texture )
set_playback_speed ( playback_speed )
video_loaded . emit ( )
## Sometimes color profiles are unknown from video files and in case that happens, the colors might be slightly off. Changing the export variable `color_profile` might help fixing the colors.
func _set_color_profile ( new_profile : COLOR_PROFILE = color_profile ) - > void :
var color_data : Vector4
var profile_str : String = video . get_color_profile ( )
color_profile = new_profile
if new_profile != COLOR_PROFILE . AUTO :
profile_str = str ( COLOR_PROFILE . find_key ( COLOR_PROFILE . BT2100 ) ) . to_lower ( )
match profile_str :
" bt2020 " , " bt2100 " : color_data = Vector4 ( 1.4746 , 0.16455 , 0.57135 , 1.8814 )
" bt601 " , " bt470 " : color_data = Vector4 ( 1.402 , 0.344136 , 0.714136 , 1.772 )
_ : color_data = Vector4 ( 1.5748 , 0.1873 , 0.4681 , 1.8556 ) # bt709 and unknown
_shader_material . set_shader_parameter ( " color_profile " , color_data )
## Seek frame can be used to switch to a frame number you want. Remember that some video codecs report incorrect video end frames or can't seek to the last couple of frames in a video file which may result in an error. Only use this when going to far distances in the video file, else you can use [code]next_frame()[/code].
func seek_frame ( new_frame_nr : int ) - > void :
if ! is_open ( ) and new_frame_nr == current_frame :
return
current_frame = clamp ( new_frame_nr , 0 , _frame_count )
if video . seek_frame ( current_frame ) :
printerr ( " Couldn ' t seek frame! " )
else :
_set_frame_image ( )
if enable_audio and audio_player . stream . get_length ( ) != 0 :
audio_player . set_stream_paused ( false )
audio_player . play ( current_frame / _frame_rate )
audio_player . set_stream_paused ( ! is_playing )
## Seeking frames can be slow, so when you just need to go a couple of frames ahead, you can use next_frame and set skip to false for the last frame.
func next_frame ( skip : bool = false ) - > void :
if video . next_frame ( skip ) and ! skip :
_set_frame_image ( )
next_frame_called . emit ( current_frame )
elif ! skip :
print ( " Something went wrong getting next frame! " )
2026-03-02 01:59:33 -06:00
2026-02-23 18:38:03 -06:00
func close ( ) - > void :
if video != null :
if is_playing :
pause ( )
video = null
#------------------------------------------------ PLAYBACK HANDLING
func _process ( delta : float ) - > void :
if is_playing :
_skips = 0
_time_elapsed += delta
if _time_elapsed < _frame_time :
return
while _time_elapsed > = _frame_time and _skips < 5 :
_time_elapsed -= _frame_time
current_frame += 1
_skips += 1
if current_frame > = _frame_count :
is_playing = ! is_playing
if enable_audio and audio_player . stream != null :
audio_player . set_stream_paused ( true )
video_ended . emit ( )
if loop :
seek_frame ( 0 )
play ( )
else :
_sync_audio_video ( )
while _skips != 1 :
next_frame ( true )
_skips -= 1
next_frame ( )
elif _video_thread != - 1 :
var error : int = WorkerThreadPool . wait_for_task_completion ( _video_thread )
if error != OK :
printerr ( " Something went wrong waiting for task completion! %s " % error )
_video_thread = - 1
_update_video ( video )
if enable_auto_play :
play ( )
## Start the video playback. This will play until reaching the end of the video and then pause and go back to the start.
func play ( ) - > void :
2026-03-02 01:59:33 -06:00
if not is_open ( ) :
print ( " The video on ' %s ' isn ' t open yet! " % path )
2026-02-23 18:38:03 -06:00
return
2026-03-02 01:59:33 -06:00
if is_playing : return
2026-02-23 18:38:03 -06:00
is_playing = true
if enable_audio and audio_player . stream . get_length ( ) != 0 :
audio_player . set_stream_paused ( false )
audio_player . play ( ( current_frame + 1 ) / _frame_rate )
audio_player . set_stream_paused ( ! is_playing )
playback_started . emit ( )
## Pausing the video.
func pause ( ) - > void :
is_playing = false
if enable_audio and audio_player . stream != null :
audio_player . set_stream_paused ( true )
playback_paused . emit ( )
## Ensures the audio playback is in sync with the video
func _sync_audio_video ( ) - > void :
if _time_elapsed < 1.20 :
return
elif enable_audio and audio_player . stream . get_length ( ) != 0 :
var audio_offset : float = audio_player . get_playback_position ( ) + AudioServer . get_time_since_last_mix ( ) - ( current_frame + 1 ) / _frame_rate
if abs ( audio_player . get_playback_position ( ) + AudioServer . get_time_since_last_mix ( ) - ( current_frame + 1 ) / _frame_rate ) > AUDIO_OFFSET_THRESHOLD :
if debug : print ( " Audio Sync: time correction: " , audio_offset )
audio_player . seek ( ( current_frame + 1 ) / _frame_rate )
audio_player . pitch_scale = playback_speed
elif audio_speed_to_sync :
if is_zero_approx ( audio_player . pitch_scale - playback_speed ) :
if audio_offset > AUDIO_OFFSET_THRESHOLD / 2 :
audio_player . pitch_scale = playback_speed * 0.99
if debug : print ( " Audio Sync: slow down " )
elif audio_offset < - AUDIO_OFFSET_THRESHOLD / 2 :
audio_player . pitch_scale = playback_speed * 1.01
if debug : print ( " Audio Sync: speed up " )
else :
if not ( audio_player . pitch_scale > playback_speed ) != not ( audio_offset < 0 ) :
audio_player . pitch_scale = playback_speed
if debug : print ( " Audio Sync: back to normal " )
#------------------------------------------------ GETTERS
## Getting the total amount of frames found in the video file.
func get_video_frame_count ( ) - > int :
return _frame_count
## Getting the framerate of the video
func get_video_framerate ( ) - > float :
return _frame_rate
## Getting the length of the video in seconds
func get_video_length ( ) - > int :
return int ( _frame_count / _frame_rate )
## Getting the current playback position of the video in seconds
func get_current_playback_position ( ) - > int :
return int ( current_frame / _frame_rate )
## Getting the rotation in degrees of the video
func get_video_rotation ( ) - > int :
return _rotation
## Check the alpha value of a video to know if this video has alpha or not
func is_video_alpha ( ) - > bool :
return _has_alpha
## Getting the title of a stream.
func get_stream_title ( stream : int ) - > String :
if not is_open ( ) :
printerr ( " Video is not open! " )
return " "
return video . get_stream_metadata ( stream ) . get ( " title " )
## Getting the language of a stream.
func get_stream_language ( stream : int ) - > String :
if not is_open ( ) :
printerr ( " Video is not open! " )
return " "
return video . get_stream_metadata ( stream ) . get ( " language " )
## Checking to see if the video is open or not, trying to run functions without checking if open can crash your project.
func is_open ( ) - > bool :
return video != null and video . is_open ( )
func _get_img_tex ( image_data : PackedByteArray , width : int , height : int , r8 : bool = true ) - > ImageTexture :
var format : Image . Format = Image . FORMAT_R8 if r8 else Image . FORMAT_RG8
var image : Image = Image . create_from_data ( width , height , false , format , image_data )
return ImageTexture . create_from_image ( image )
#------------------------------------------------ SETTERS
func _set_current_frame ( new_current_frame : int ) - > void :
current_frame = new_current_frame
frame_changed . emit ( current_frame )
func _set_frame_image ( ) - > void :
RenderingServer . texture_2d_update ( y_texture . get_rid ( ) , video . get_y_data ( ) , 0 )
RenderingServer . texture_2d_update ( u_texture . get_rid ( ) , video . get_u_data ( ) , 0 )
RenderingServer . texture_2d_update ( v_texture . get_rid ( ) , video . get_v_data ( ) , 0 )
if _has_alpha :
RenderingServer . texture_2d_update ( a_texture . get_rid ( ) , video . get_a_data ( ) , 0 )
func set_playback_speed ( new_playback_value : float ) - > void :
playback_speed = clampf ( new_playback_value , 0.5 , 2 )
_frame_time = ( 1.0 / _frame_rate ) / playback_speed
if enable_audio and audio_player . stream != null :
audio_player . pitch_scale = playback_speed
_set_pitch_adjust ( )
if is_playing :
audio_player . play ( current_frame * ( 1.0 / _frame_rate ) )
func set_pitch_adjust ( new_pitch_value : bool ) - > void :
pitch_adjust = new_pitch_value
_set_pitch_adjust ( )
func _set_pitch_adjust ( ) - > void :
if pitch_adjust :
_audio_pitch_effect . pitch_scale = clamp ( 1.0 / playback_speed , 0.5 , 2.0 )
elif _audio_pitch_effect . pitch_scale != 1.0 :
_audio_pitch_effect . pitch_scale = 1.0
func set_audio_stream ( stream : int ) - > void :
if not is_open ( ) :
printerr ( " Video is not open! " )
return
2026-03-02 01:59:33 -06:00
2026-02-23 18:38:03 -06:00
if not stream in audio_streams :
printerr ( " Invalid audio stream! " )
return
if enable_audio :
_open_audio ( stream )
if is_playing and audio_player . stream . get_length ( ) != 0 :
audio_player . set_stream_paused ( false )
audio_player . play ( current_frame / _frame_rate )
audio_player . set_stream_paused ( ! is_playing )
#------------------------------------------------ MISC
## Converts the given duration as seconds in a formatted string. (hh):mm:ss
func duration_to_formatted_string ( duration_in_seconds : float ) - > String :
var hours : int = floori ( duration_in_seconds / 3600.0 )
var minutes : int = floori ( duration_in_seconds / 60.0 ) % 60
var seconds : int = floori ( duration_in_seconds ) % 60
if hours == 0 :
return " %02d : %02d " % [ minutes , seconds ]
return " %02d : %02d : %02d " % [ hours , minutes , seconds ]
func _open_video ( ) - > void :
if video . open ( path ) :
printerr ( " Error opening video! " )
func _open_audio ( stream_id : int = - 1 ) - > void :
var stream : AudioStreamFFmpeg = AudioStreamFFmpeg . new ( )
if stream . open ( path , stream_id ) != OK :
printerr ( " Failed to open AudioStreamFFmpeg for: %s " % path )
return
2026-03-02 01:59:33 -06:00
audio_player . stream = stream
2026-02-23 18:38:03 -06:00
func _print_stream_info ( streams : PackedInt32Array ) - > void :
for i : int in range ( len ( streams ) ) :
var metadata : Dictionary = video . get_stream_metadata ( streams [ i ] )
var title : String = metadata . get ( " title " )
var language : String = metadata . get ( " language " )
if title == " " :
title = " Track " + str ( i + 1 )
if language != " " :
title += " - %s " % language
print ( " - %s " % title )
func _print_system_debug ( ) - > void :
print_rich ( " [b]System info " )
print ( " OS name: " , OS . get_name ( ) )
print ( " Distro name: " , OS . get_distribution_name ( ) )
print ( " OS version: " , OS . get_version ( ) )
print_rich ( " Memory info: \n \t " , OS . get_memory_info ( ) )
print ( " CPU name: " , OS . get_processor_name ( ) )
print ( " Threads count: " , OS . get_processor_count ( ) )
func _print_video_debug ( ) - > void :
print_rich ( " [b]Video debug info " )
print ( " Extension: " , path . get_extension ( ) )
print ( " Resolution: " , _resolution )
print ( " Actual resolution: " , video . get_actual_resolution ( ) )
print ( " Pixel format: " , video . get_pixel_format ( ) )
print ( " Color profile: " , video . get_color_profile ( ) )
print ( " Framerate: " , _frame_rate )
print ( " Duration (in frames): " , _frame_count )
print ( " Padding: " , _padding )
print ( " Rotation: " , _rotation )
print ( " Alpha: " , _has_alpha )
print ( " Full color range: " , video . is_full_color_range ( ) )
print ( " Interlaced flag: " , video . get_interlaced ( ) )
print ( " Using sws: " , video . is_using_sws ( ) )
print ( " Sar: " , video . get_sar ( ) )
2026-03-02 01:59:33 -06:00
print_rich ( " Video streams: [i]( %s ) " % video_streams . size ( ) )
_print_stream_info ( video_streams )
2026-02-23 18:38:03 -06:00
if audio_streams . size ( ) != 0 :
print_rich ( " Audio streams: [i]( %s ) " % audio_streams . size ( ) )
_print_stream_info ( audio_streams )
2026-03-02 01:59:33 -06:00
elif debug :
2026-02-23 18:38:03 -06:00
print ( " No audio streams found. " )
if subtitle_streams . size ( ) != 0 :
print_rich ( " Subtitle streams: [i]( %s ) " % subtitle_streams . size ( ) )
_print_stream_info ( subtitle_streams )
2026-03-02 01:59:33 -06:00
elif debug :
2026-02-23 18:38:03 -06:00
print ( " No subtitle streams found. " )
2026-03-02 01:59:33 -06:00
2026-02-23 18:38:03 -06:00
if chapters . size ( ) != 0 :
print_rich ( " Chapters: [i]( %s ) " % chapters . size ( ) )
for i : int in range ( chapters . size ( ) ) :
var title : String = chapters [ i ] . title
if title == " " :
title = " Chapter " + str ( i + 1 )
print ( " - %s - %s - %s " % [
duration_to_formatted_string ( chapters [ i ] . start ) ,
duration_to_formatted_string ( chapters [ i ] . end ) ,
title
] )
else :
print ( " No chapters found. " )
class Chapter :
var start : float ## Start of the chapter in seconds.
var end : float ## End of the chapter in seconds.
var title : String
func _init ( _start : float , _end : float , _title : String ) - > void :
start = _start
end = _end
title = _title