Видео: запуск, пауза и перемотка
Полный интерфейс C#, Theora OGV, сохранение пропорций, цикл и хромакей.
Поддерживаемый файл
Встроенный декодер этой поставки читает Theora в контейнере Ogg, расширение .ogv. Расширение не перекодирует содержимое: переименование MP4 в OGV не поможет. Другие форматы требуют отдельной реализации VideoStream/расширения; встроенную поддержку MP4, H.264 или WebM здесь не обещает ни код, ни пример.
Скачать учебный OGV для Reckfell 1.0.0. Это собственный ролик из примера записи кадров: 512 × 288, 30 кадров/с, 4 с. Для обучения не нужны внешние видеоматериалы или установка кодировщика.
Запустить интерфейс
Создайте уровень с корнем Control. Сохраните ролик в Clips/recording-slate.ogv, добавьте VideoDemo.cs, прикрепите к корню, соберите C#-проект и запустите F6. Нажмите Запустить. Синий квадрат движется по экрану; Пауза сохраняет позицию, Продолжить возобновляет воспроизведение, Остановить прекращает его. Следующее нажатие Запустить начинает ролик сначала.
VideoDemo.cs
using System;
using Reckfell;
public partial class VideoDemo : Control
{
[Export(PropertyHint.File,"*.ogv")] public string VideoPath { get; set; } = "asset://Clips/recording-slate.ogv";
public VideoStreamPlayer Video { get; private set; } = null!;
public Label Status { get; private set; } = null!;
public Button PauseButton { get; private set; } = null!;
public int FinishedCount { get; private set; }
private bool _russian;
private string Text(string ru,string en) => _russian ? ru : en;
public override void _Ready()
{
_russian=TranslationServer.GetLocale().StartsWith("ru",StringComparison.OrdinalIgnoreCase);
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
var layout=new VBoxContainer { Name="Layout" }; AddChild(layout); layout.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
var aspect=new AspectRatioContainer
{
Name="Aspect",Ratio=16f/9,StretchMode=AspectRatioContainer.StretchModeEnum.Fit,
SizeFlagsVertical=SizeFlags.ExpandFill
};
Video=new VideoStreamPlayer { Name="Video",Expand=true,Autoplay=false,Loop=false };
layout.AddChild(aspect); aspect.AddChild(Video);
var buttons=new HBoxContainer { Name="Buttons" }; layout.AddChild(buttons);
var play=new Button { Name="PlayButton",Text=Text("Запустить","Play") };
PauseButton=new Button { Name="PauseButton",Text=Text("Пауза","Pause") };
var stop=new Button { Name="StopButton",Text=Text("Остановить","Stop") };
buttons.AddChild(play); buttons.AddChild(PauseButton); buttons.AddChild(stop);
Status=new Label { Name="Status",Text=Text("Готово к запуску","Ready") }; layout.AddChild(Status);
play.Pressed+=() => ShowResult(LoadAndPlay());
PauseButton.Pressed+=TogglePause;
stop.Pressed+=StopVideo;
Video.Finished+=() =>
{
FinishedCount++;
Status.Text=Text("Воспроизведение завершено","Finished");
PauseButton.Text=Text("Пауза","Pause");
};
}
public Error LoadAndPlay()
{
if (!IsInsideTree()) return Error.Unconfigured;
if (!FileAccess.FileExists(VideoPath)) return Error.FileNotFound;
if (!VideoPath.EndsWith(".ogv",StringComparison.OrdinalIgnoreCase)) return Error.Unavailable;
var stream=ResourceLoader.Load<VideoStream>(VideoPath);
if (stream is null) return Error.FileUnrecognized;
Video.Paused=false; Video.Stream=stream; Video.Play();
PauseButton.Text=Text("Пауза","Pause");
Status.Text=Text("Воспроизведение","Playing");
return Error.Ok;
}
private void ShowResult(Error error)
{
if (error != Error.Ok) Status.Text=$"{Text("Ошибка","Error")}: {error}";
}
public void TogglePause()
{
if (!Video.IsPlaying()) return;
Video.Paused=!Video.Paused;
PauseButton.Text=Video.Paused ? Text("Продолжить","Resume") : Text("Пауза","Pause");
Status.Text=Video.Paused ? Text("Пауза","Paused") : Text("Воспроизведение","Playing");
}
public void StopVideo()
{
Video.Stop(); Video.Paused=false;
PauseButton.Text=Text("Пауза","Pause"); Status.Text=Text("Остановлено","Stopped");
}
public Error SeekTo(double seconds)
{
double length=Video.GetStreamLength();
if (!double.IsFinite(seconds) || seconds<0 || length<=0 || seconds>=length) return Error.InvalidParameter;
Video.StreamPosition=seconds;
return Error.Ok;
}
}UI выбирает RU или EN при создании по TranslationServer.GetLocale(). LoadAndPlay() проверяет путь до замены текущего потока; отсутствующий файл не останавливает уже играющий ролик. На первом запуске без файла статус покажет ошибку вместо обращения к null.
Размер и пропорции
Expand = true позволяет контейнеру управлять размером VideoStreamPlayer вместо минимального размера кадра. AspectRatioContainer с Ratio = 16/9 и Fit сохраняет пропорции. Для ролика с другими пропорциями задайте соответствующий Ratio; чёрные поля при несовпадении сторон — нормальны. Растягивание самого проигрывателя на произвольный прямоугольник не гарантирует сохранение пропорций.
Время и завершение
| Операция | Контракт |
|---|---|
Paused = true | Приостанавливает время; IsPlaying() при этом может оставаться true |
Paused = false | Продолжает с сохранённой позиции |
Stop() | Останавливает; это не естественное окончание и не сигнал Finished |
StreamPosition = seconds | У текущего Theora-декодера есть перемотка; задавайте допустимое время внутри длины |
Loop = true | Повторяет ролик, не выдавая Finished на каждом обороте |
SeekTo() принимает секунды и отвергает отрицательные значения, NaN и позицию на/за концом. Декодирование может пройти от ключевого кадра, поэтому перемотка длинного файла не обязана быть мгновенной. Новый видимый кадр проверяйте после обработки декодера; присваивание времени не является сигналом завершения. Нельзя автоматически переносить поведение Theora на произвольный плагин VideoStream.
Цикл не является обещанием бесшовного звука и отсутствия паузы на стыке. Для игрового состояния и отмены храните отдельный флаг, а не вечное ожидание Finished у зацикленного ролика.
Звук и нагрузка
Проверьте AudioTrack, Bus, VolumeDb и слышимость шины. В учебном ролике звуковая дорожка тихая намеренно. Theora декодируется на CPU: измеряйте разрешение, FPS и число одновременных потоков на целевом устройстве. BufferingMsec не включает аппаратное декодирование и не исправляет отсутствующий кодек. Внешний сетевой URL не становится HTTP-стримом от одного присваивания VideoPath; пример читает локальные ресурсы.
Видео на поверхности 3D
Получите живую текстуру через GetVideoTexture() и назначьте её StandardMaterial3D.AlbedoTexture; материалу можно выбрать Unshaded, если освещение не должно менять видео. Сам VideoStreamPlayer оставьте в дереве и запустите. Не копируйте каждый кадр через GetImage() для обычного показа — это ненужное чтение с GPU. Если нужна целая интерфейсная композиция, отрисуйте её в SubViewport и используйте его текстуру.
Хромакей
Сохраните код как ChromaKey.reckfellshader, создайте ShaderMaterial и назначьте его свойству Material проигрывателя. Цвет по умолчанию зелёный; threshold и положительная softness управляют удалением фона и мягкостью границы. Шейдер сохраняет Modulate через цвет вершин.
shader_type canvas_item;
uniform vec4 key_color : source_color = vec4(0.0, 1.0, 0.0, 1.0);
uniform float threshold : hint_range(0.0, 1.0) = 0.15;
uniform float softness : hint_range(0.001, 0.3) = 0.03;
varying vec4 tint;
void vertex() {
tint = COLOR;
}
void fragment() {
vec4 pixel = texture(TEXTURE, UV);
float difference = distance(pixel.rgb, key_color.rgb);
float alpha = smoothstep(threshold - softness, threshold + softness, difference);
COLOR = pixel * tint;
COLOR.a *= alpha;
}Theora-файл не получает альфа-канал от этого шейдера: прозрачность вычисляется при показе. Сжатие смешивает цвета у границ, поэтому чистый фон исходника и умеренный порог важнее попытки исправить всё одним значением.
Диагностика
| Симптом | Проверка |
|---|---|
FileNotFound | Имя, регистр, папка Clips и включение ресурса в экспорт |
FileUnrecognized | Настоящий кодек Theora, корректный Ogg и доступный декодер |
| Кнопка Запустить ничего не показывает | Лог ошибок, размер Control и валидность Stream |
| Видео растянуто | Expand, Ratio и StretchMode контейнера |
| Позиция не меняется | Paused, скорость, пауза дерева и обработка кадров |
| Finished не приходит | Loop или явная остановка вместо окончания |
| MP4 работает в браузере, но не здесь | Браузерный декодер не добавляет формат в VideoStreamPlayer |