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() 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. func update_video(video_instance: GoZenVideo, audio_stream: AudioStreamWAV = null) -> void: if video != null: close() path = video_instance.get_path() _update_video(video_instance) if audio_stream: audio_player.stream = audio_stream else: _open_audio() ## 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. 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) 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) 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!") 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: if not is_open(): print("The video on '%s' isn't open yet!" % path) return if is_playing: return 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 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 audio_player.stream = stream 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()) print_rich("Video streams: [i](%s)" % video_streams.size()) _print_stream_info(video_streams) if audio_streams.size() != 0: print_rich("Audio streams: [i](%s)" % audio_streams.size()) _print_stream_info(audio_streams) elif debug: print("No audio streams found.") if subtitle_streams.size() != 0: print_rich("Subtitle streams: [i](%s)" % subtitle_streams.size()) _print_stream_info(subtitle_streams) elif debug: print("No subtitle streams found.") 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