Skip to content

Instantly share code, notes, and snippets.

@esperecyan
Last active August 23, 2023 10:25
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save esperecyan/49b6e4f885dc9ffa6b17d4f4cd5e4d8c to your computer and use it in GitHub Desktop.
Save esperecyan/49b6e4f885dc9ffa6b17d4f4cd5e4d8c to your computer and use it in GitHub Desktop.
■『convert-video-to-texture-for-vrchat-quest.ps1.jse』MP4などの動画から、一定間隔ごとにUVを一瞬でズラすことができるシステム向けのテクスチャに変換するスクリプトです。 ■『CreateFrameByFrameAnimation.cs』 VRChatのQuest版における動画再生用のエディタ拡張。テクスチャをHierarchey上のメッシュオブジェクトに設定しておき、そのオブジェクトのコンテクストメニューから生成します。 ■『VCI埋め込み動画再生ライブラリ』バーチャルキャストのアイテム・背景に動画を埋め込めるようにするLuaスクリプト片。 https://twitter.com/esperecyan/status/115569878…
#@~^AQAAAA==~IAAAAA==^#~@ function toPSString(str) { return "'" + str.replace(/%/g, '"%"').replace(/'/g, "''") + "'"; } /* -*- mode: powershell;-*-
<#*/ var command = 'param($Path, $FrameCount, $TextureSize = 8192, $Extension = \'.png\', [switch]$Fill, $TextureCount = 1, $Tile)'
+ '; $_PSCommandPath = ' + toPSString(WSH.ScriptFullName)
+ '; Invoke-Expression (Get-Content ' + toPSString(WSH.ScriptFullName) + ' -Encoding UTF8 -Raw)';
var namePattern = /^-(?!(?:b(?:and|or|xor|not)|sh[lr]|[ic]?(?:eq|ne|gt|ge|lt|le|(?:not)?(?:like|match|contains|in)|replace|split)|join|is(?:not)?|as|and|or|not|f)$)[0-9a-z]+$/i;
var args = ''; for (var i = 0; i < WSH.Arguments.Length; i++) {
var arg = WSH.Arguments(i); args += ' ' + (namePattern.test(arg) ? arg : toPSString(arg)); }
WSH.CreateObject('WScript.Shell').Run('PowerShell -NoExit -Command &{' + command + '}' + args); /*#>
<#
.SYNOPSIS
MP4などの動画から、一定間隔ごとにUVを一瞬でズラすことができるシステム向けのテクスチャに変換するスクリプトです。
.DESCRIPTION
あらかじめパスの通った場所にffmpegをインストールしておく必要があります。
参照:
100の人さんのツイート: “VRChat Quest版におけるパフォーマンスランクをMediumに抑えてアニラボのロゴを再生。板ポリでいいのでポリゴンの形状を気にする必要がなくやポリゴン数制限にも優しい。途中いろいろ教えてくれたFuriaさんに感謝! https://t.co/E6kryJnzpw ↑アニメーションファイル生成用のUnityエディタ拡張を公開… https://t.co/b0ukdENkxs”
<https://twitter.com/esperecyan/status/1155698784380178433>
SPDX-License-Identifier: MPL-2.0
SPDX-FileCopyrightText: 2019 100の人
配布元: https://gist.github.com/esperecyan/49b6e4f885dc9ffa6b17d4f4cd5e4d8c
.EXAMPLE
convert-video-to-texture-for-vrchat-quest.ps1.jse -Path test.mp4 -FrameCount 1000
.PARAMETER Path
動画ファイルのパス。
.PARAMETER FrameCount
出力時のフレーム数。
.PARAMETER TextureSize
出力テクスチャの一辺のサイズ。既定値は8192。
.PARAMETER Extension
「.」で始まる出力形式の拡張子。既定値は「.png」
.PARAMETER Fill
指定されていれば、アスペクト比を変更し、出力テクスチャサイズへできるだけ近付けます。
.PARAMETER TextureCount
何枚のテクスチャへ分割して出力するか。既定値は1。
フレーム数との兼ね合いにより、出力枚数が1枚少なくなる場合があります。
.PARAMETER Tile
1枚のテクスチャのタイリング構成。「2x4」(水平フレーム数x垂直フレーム数) のように指定
指定した場合、 -TextureCount は無視されます。
#>
using namespace System.IO
using namespace System.Windows.Forms
Set-StrictMode -Version Latest; $ErrorActionPreference = 'Stop';
$PSCommandPath = $_PSCommandPath; $PSScriptRoot = Split-Path $PSCommandPath -Parent
Add-Type -AssemblyName @('System.Windows.Forms')
$Path = Resolve-Path $Path
$ErrorActionPreference = 'SilentlyContinue'
$videoInfo = (ffprobe -show_streams -print_format json $Path 2>$null | ConvertFrom-Json).streams[0]
$ErrorActionPreference = 'Stop'
if ($Tile) {
[int]$horizontallyFrameCount, [int]$verticallyFrameCount = $Tile -split 'x'
$TextureCount = [Math]::Ceiling($FrameCount / ($horizontallyFrameCount * $verticallyFrameCount))
} else {
$frameCountByTexture = $FrameCount / $TextureCount
$side = [Math]::Sqrt($videoInfo.Width * $videoInfo.Height * $frameCountByTexture)
$horizontallyFrameCount = [Math]::Ceiling($side / $videoInfo.Width)
$verticallyFrameCount = [Math]::Ceiling($frameCountByTexture / $horizontallyFrameCount)
}
if ($Fill) {
$scale = "$($TextureSize / $horizontallyFrameCount):$($TextureSize / $verticallyFrameCount)"
} else {
$magnification = [Math]::Min(
1.0,
$TextureSize / [Math]::Max($videoInfo.Width * $horizontallyFrameCount, $videoInfo.Height * $verticallyFrameCount)
)
$scale = "$($videoInfo.Width * $magnification):$($videoInfo.Height * $magnification)"
}
$destinationFileName = "$([Path]::GetFileNameWithoutExtension($Path))-$($FrameCount)f-"
if ($TextureCount -gt 1) {
$destinationFileName += "$($horizontallyFrameCount)x$($verticallyFrameCount)"
} else {
$destinationFileName += "x$($horizontallyFrameCount)f"
}
$destinationFileName += "-$($videoInfo.duration)s"
if ($TextureCount -gt 1) {
$destinationFileName += '-%03d'
}
$destinationFileName += $Extension
$destinationPath = Join-Path (Split-Path $Path -Parent) $destinationFileName
$q = ''
if ($Extension -match '^.jpe?g$') {
$q = '-q', 2
}
ffmpeg -i $Path `
-vf scale=$scale,fps=$($FrameCount / $videoInfo.duration),tile=layout=${horizontallyFrameCount}x$verticallyFrameCount `
$q `
$destinationPath -y
[MessageBox]::Show("$destinationPath へ保存しました。", (Split-Path -Path @($PSCommandPath) -Leaf))
(Get-Process -Id $pid).CloseMainWindow() | Out-Null # */
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Text.RegularExpressions;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Events;
using UnityEditor;
#if VRC_SDK_VRCSDK2
using VRCSDK2;
#endif
namespace Esperecyan.Unity.CreateFrameByFrameAnimation
{
/// <summary>
/// VRChatのQuest版における動画再生システム構築を補助するエディタ拡張。
/// </summary>
/// <remarks>
/// 【GameObject ▶ UnityEditorScripts ▶ コマ送りアニメーションを生成 (IDLE用)】
/// 【GameObject ▶ UnityEditorScripts ▶ コマ送りアニメーションを生成】
///
/// テクスチャをHierarchey上のメッシュオブジェクトに設定しておき、そのオブジェクトを選択した状態で実行します。
///
/// テクスチャ名を「◯◯◯◯◯-全フレーム数f-x横方向のフレーム数f-秒数s」のように設定してください。
/// (例: logo1textureoriginal-73f-x10f-3.5s.png)
/// (参照: <https://twitter.com/esperecyan/status/1155834954879660033>)
///
/// UV、もしくはマテリアルの設定でTiling、Offsetを調整し、左上 (1枚目) のフレームに合わせておきます。
///
/// 【GameObject ▶ UnityEditorScripts ▶ コマ送りアニメーション同期用のカスタムトリガーを追記生成】
/// 【GameObject ▶ UnityEditorScripts ▶ コマ送りアニメーション同期用のアニメーションを生成】
/// 【GameObject ▶ UnityEditorScripts ▶ コマ送りアニメーションの音声同期用のButtonを生成】
/// 【GameObject ▶ UnityEditorScripts ▶ コマ送りアニメーションの音声同期用のButtonを有効化するアニメーションを生成】
///
/// 再生途中でJOINした人と映像再生位置を同期するために使う
/// カスタムトリガーをVRC_Triggerへ追記、およびそれを呼び出すAnimation Eventを列記したanimファイルを生成します。
///
/// また、音声再生位置を同期するために使う
/// Buttonを設定したオブジェクトをAudioSource以下に作成、およびそれを有効化するanimファイルを生成します。
///
/// Animatorコンポーネント、およびVRC_Triggerコンポーネントが設定されたオブジェクトを選択した状態で実行します。
///
/// 動作確認バージョン: Unity 2019.4.31f1
/// ライセンス: Mozilla Public License 2.0 (MPL-2.0) <https://spdx.org/licenses/MPL-2.0.html>
/// 配布元: <https://twitter.com/esperecyan/status/1155698784380178433>
/// </remarks>
internal class CreateFrameByFrameAnimation
{
/// <summary>
/// 当エディタ拡張の名前とバージョン番号。
/// </summary>
/// <remarks>
/// 1.0.0 (2019-12-04)
/// 再生途中でJOINした人と再生位置を同期するシステム構築を補助する機能を追加
/// 再生時間が1フレーム分短くなり最後のフレームが表示されない問題を解消
///
/// [中略]
///
/// バージョン番号なし (2019-07-29)
/// 公開
/// </remarks>
internal static readonly string Name = "CreateFrameByFrameAnimation.cs-1.0.0";
private static readonly string TemplatePath = "Assets/VRCSDK/Examples/Sample Assets/Animation/Idle.fbx";
private static void DisplayMainTextureNotFoundDialog()
{
EditorUtility.DisplayDialog(
CreateFrameByFrameAnimation.Name,
"Assets内に存在するメインテクスチャが設定されたマテリアルを含むオブジェクトを選択した状態で実行してください。",
"OK"
);
}
[MenuItem("GameObject/UnityEditorScripts/コマ送りアニメーションを生成 (IDLE用)")]
private static void CreateForIdle()
{
CreateFrameByFrameAnimation.Create(forIdle: true);
}
[MenuItem("GameObject/UnityEditorScripts/コマ送りアニメーションを生成")]
private static void CreateForAnimator()
{
CreateFrameByFrameAnimation.Create(forIdle: false);
}
private static void Create(bool forIdle)
{
var gameObject = Selection.activeObject as GameObject;
if (!gameObject)
{
CreateFrameByFrameAnimation.DisplayMainTextureNotFoundDialog();
return;
}
Type type = new[] { typeof(MeshRenderer), typeof(SkinnedMeshRenderer) }
.FirstOrDefault(t => gameObject.GetComponent(t));
if (type == null)
{
CreateFrameByFrameAnimation.DisplayMainTextureNotFoundDialog();
return;
}
Material material = (type == typeof(MeshRenderer)
? gameObject.GetComponent<MeshRenderer>().sharedMaterial
: gameObject.GetComponent<SkinnedMeshRenderer>().sharedMaterial);
if (!material)
{
CreateFrameByFrameAnimation.DisplayMainTextureNotFoundDialog();
return;
}
Texture texture = material.mainTexture;
if (!texture)
{
CreateFrameByFrameAnimation.DisplayMainTextureNotFoundDialog();
return;
}
string texturePath = AssetDatabase.GetAssetPath(texture);
if (string.IsNullOrEmpty(texturePath))
{
CreateFrameByFrameAnimation.DisplayMainTextureNotFoundDialog();
return;
}
Match match = Regex.Match(
texture.name,
@"-(?<frameCount>[1-9][0-9]*)f-x(?<horizontallyFrameCount>[1-9][0-9]*)f\-(?<seconds>[1-9][0-9]*(?:\.[0-9]+)?)s$"
);
if (!match.Success)
{
EditorUtility.DisplayDialog(
CreateFrameByFrameAnimation.Name,
"ファイル名を「◯◯◯◯◯-全フレーム数f-x横方向のフレーム数f-秒数s」のように設定してください。\n"
+ "(例: logo1textureoriginal-73f-x10f-3.5s.png)",
"OK"
);
return;
}
var frameCount = int.Parse(match.Groups["frameCount"].Value);
var horizontallyFrameCount = int.Parse(match.Groups["horizontallyFrameCount"].Value);
var seconds = float.Parse(match.Groups["seconds"].Value);
var clip = new AnimationClip();
AnimationClipSettings settings = AnimationUtility.GetAnimationClipSettings(clip);
settings.loopTime = true;
settings.loopBlendPositionY = true;
settings.keepOriginalPositionY = true;
AnimationUtility.SetAnimationClipSettings(clip, settings);
float framePeriod = seconds / frameCount;
var verticallyFrameCount = (frameCount - 1) / horizontallyFrameCount + 1;
var offsetXCurve = new AnimationCurve();
var offsetYCurve = new AnimationCurve();
Vector2 defaultOffset = material.mainTextureOffset;
var offset = new Vector2();
for (var i = 0; i <= frameCount; i++)
{
var sameOfLastFrameForTotalTime = i == frameCount;
if (!sameOfLastFrameForTotalTime)
{
offset = new Vector2(
x: defaultOffset.x + (float)(i % horizontallyFrameCount) / horizontallyFrameCount,
y: defaultOffset.y - (float)(i / horizontallyFrameCount) / verticallyFrameCount
);
}
var time = sameOfLastFrameForTotalTime ? seconds : framePeriod * i;
offsetXCurve.AddKey(new Keyframe(time, offset.x));
AnimationUtility.SetKeyLeftTangentMode(offsetXCurve, i, AnimationUtility.TangentMode.Constant);
offsetYCurve.AddKey(new Keyframe(time, offset.y));
AnimationUtility.SetKeyLeftTangentMode(offsetYCurve, i, AnimationUtility.TangentMode.Constant);
}
var relativePath = forIdle
? CreateFrameByFrameAnimation
.RelativePathFrom(self: gameObject.transform, root: gameObject.transform.root)
: "";
Vector2 textureScale = material.mainTextureScale;
var scaleXCurve = new AnimationCurve(new Keyframe(0, textureScale.x));
AnimationUtility.SetKeyLeftTangentMode(scaleXCurve, 0, AnimationUtility.TangentMode.Constant);
clip.SetCurve(relativePath, type, "material._MainTex_ST.x", scaleXCurve);
var scaleYCurve = new AnimationCurve(new Keyframe(0, textureScale.y));
AnimationUtility.SetKeyLeftTangentMode(scaleYCurve, 0, AnimationUtility.TangentMode.Constant);
clip.SetCurve(relativePath, type, "material._MainTex_ST.y", scaleYCurve);
clip.SetCurve(relativePath, type, "material._MainTex_ST.z", offsetXCurve);
clip.SetCurve(relativePath, type, "material._MainTex_ST.w", offsetYCurve);
if (forIdle)
{
var idleTemplate
= AssetDatabase.LoadAssetAtPath<AnimationClip>(CreateFrameByFrameAnimation.TemplatePath);
foreach (EditorCurveBinding binding in AnimationUtility.GetCurveBindings(idleTemplate))
{
var curve = new AnimationCurve(AnimationUtility.GetEditorCurve(idleTemplate, binding).keys[0]);
AnimationUtility.SetKeyLeftTangentMode(curve, 0, AnimationUtility.TangentMode.Constant);
AnimationUtility.SetEditorCurve(clip, binding, curve);
}
}
string destinationPath = Path.GetDirectoryName(texturePath) + "/"
+ Path.GetFileNameWithoutExtension(texturePath) + (forIdle ? "-idle" : "") + ".anim";
var destination = AssetDatabase.LoadAssetAtPath<AnimationClip>(destinationPath);
if (destination)
{
EditorUtility.CopySerialized(clip, destination);
}
else
{
AssetDatabase.CreateAsset(clip, destinationPath);
}
EditorUtility.DisplayDialog(
CreateFrameByFrameAnimation.Name,
"以下のファイルへ生成しました。\n" + destinationPath,
"OK"
);
}
/// <summary>
/// Hierarchyのパスを返します。
/// </summary>
/// <remarks>
/// MIT Licenseで提供されているライブラリに含まれるメソッド。
/// <https://github.com/vrm-c/UniVRM/blob/v0.53.0/Assets/VRM/UniGLTF/Scripts/Extensions/UnityExtensions.cs#L159-L173>
///
/// MIT License
///
/// Copyright(c) 2018 ousttrue
///
/// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files(the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
///
/// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
///
/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
/// </remarks>
/// <param name="self"></param>
/// <param name="root"></param>
/// <returns></returns>
internal static string RelativePathFrom(Transform self, Transform root)
{
var path = new List<String>();
for (var current = self; current != null; current = current.parent)
{
if (current == root)
{
return String.Join("/", path.ToArray());
}
path.Insert(0, current.name);
}
throw new Exception("no RelativePath");
}
}
#if VRC_SDK_VRCSDK2
internal class VRChatVideoSync
{
/// <summary>
/// VRC_Triggerを使ってMasterの再生位置を保存するパラメータ名。
/// </summary>
private static readonly string AnimatorControllerBufferingParameterName = "master-offset";
/// <summary>
/// VRC_Triggerを使ってMasterの再生位置を保存する際、同時に MasterUnbuffered で <c>false</c> をセットするパラメータ名。
/// </summary>
private static readonly string AnimatorControllerSwitchingParameterName = "late-joiner";
private static readonly float GeneratedAnimationTotalSeconds = 1000 / 60;
private static readonly string CustomTriggerNamePrefix = "offset-";
[MenuItem("GameObject/UnityEditorScripts/コマ送りアニメーション同期用のカスタムトリガーを追記生成")]
private static void AppendCustomTriggers()
{
GameObject gameObject = VRChatVideoSync.GetSelectedObject();
VRC_Trigger vrcTrigger = VRChatVideoSync.GetVRCTrigger(gameObject: gameObject);
if (!vrcTrigger)
{
return;
}
CountWizard.PromptCount(callback: divisionCount => {
if (divisionCount == 0)
{
return;
}
var triggers = vrcTrigger.Triggers;
if (triggers == null)
{
triggers = new List<VRC_Trigger.TriggerEvent>();
}
for (var i = 0; i < divisionCount; i++) // 50 の場合、offset-0、offset-1/50 〜 offset-49/50 を生成
{
triggers.Add(new VRC_Trigger.TriggerEvent()
{
TriggerType = VRC_Trigger.TriggerType.Custom,
BroadcastType = VRC_EventHandler.VrcBroadcastType.MasterUnbuffered,
Name = VRChatVideoSync.CustomTriggerNamePrefix + i + (i > 0 ? "/" + divisionCount : ""),
Events = new List<VRC_EventHandler.VrcEvent>()
{
new VRC_EventHandler.VrcEvent()
{
EventType = VRC_EventHandler.VrcEventType.AnimationFloat,
ParameterObject = gameObject,
ParameterString = VRChatVideoSync.AnimatorControllerBufferingParameterName,
ParameterFloat = (float)i / divisionCount,
},
new VRC_EventHandler.VrcEvent()
{
EventType = VRC_EventHandler.VrcEventType.AnimationBool,
ParameterObject = gameObject,
ParameterString = VRChatVideoSync.AnimatorControllerSwitchingParameterName,
ParameterBoolOp = VRC_EventHandler.VrcBooleanOp.False,
},
},
});
}
EditorUtility.DisplayDialog(CreateFrameByFrameAnimation.Name, "追記が完了しました。", "OK");
});
}
[MenuItem("GameObject/UnityEditorScripts/コマ送りアニメーション同期用のアニメーションを生成")]
private static void CreateAnimation()
{
GameObject gameObject = VRChatVideoSync.GetSelectedObject();
VRC_Trigger vrcTrigger = VRChatVideoSync.GetVRCTrigger(gameObject: gameObject);
if (!vrcTrigger)
{
return;
}
var namesAndSeconds = new Dictionary<string, float>();
var pattern = new Regex("^" + Regex.Escape(VRChatVideoSync.CustomTriggerNamePrefix) + "([0-9]+)/([0-9]+)$");
foreach (VRC_Trigger.TriggerEvent trigger in vrcTrigger.Triggers)
{
Match match = pattern.Match(trigger.Name);
if (!match.Success)
{
continue;
}
namesAndSeconds.Add(
trigger.Name,
(float)int.Parse(match.Groups[1].Value) * VRChatVideoSync.GeneratedAnimationTotalSeconds
/ int.Parse(match.Groups[2].Value)
);
}
var clip = new AnimationClip();
AnimationClipSettings settings = AnimationUtility.GetAnimationClipSettings(clip);
settings.loopTime = true;
AnimationUtility.SetAnimationClipSettings(clip, settings);
AnimationUtility.SetAnimationEvents(clip, namesAndSeconds.Select(nameAndSecond => new AnimationEvent()
{
time = nameAndSecond.Value,
functionName = "ExecuteCustomTrigger",
stringParameter = nameAndSecond.Key,
}).Concat(new AnimationEvent[]
{
new AnimationEvent()
{
time = 0,
functionName = "ExecuteCustomTrigger",
stringParameter = VRChatVideoSync.CustomTriggerNamePrefix + "0",
},
new AnimationEvent()
{
time = VRChatVideoSync.GeneratedAnimationTotalSeconds,
functionName = "ExecuteCustomTrigger",
stringParameter = VRChatVideoSync.CustomTriggerNamePrefix + "0",
},
}).ToArray());
string destinationPath
= AssetDatabase.GenerateUniqueAssetPath("Assets/vrchat-video-sync-animation-triggers.anim");
AssetDatabase.CreateAsset(clip, destinationPath);
EditorUtility.DisplayDialog(
CreateFrameByFrameAnimation.Name,
"以下のファイルへ生成しました。\n" + destinationPath,
"OK"
);
}
[MenuItem("GameObject/UnityEditorScripts/コマ送りアニメーションの音声同期用のButtonを生成")]
private static void CreateButtons()
{
GameObject gameObject = VRChatVideoSync.GetSelectedObject();
VRC_Trigger vrcTrigger = VRChatVideoSync.GetVRCTrigger(gameObject: gameObject);
if (!vrcTrigger)
{
return;
}
var divisionCount = 0;
var pattern = new Regex("^" + Regex.Escape(VRChatVideoSync.CustomTriggerNamePrefix) + "[0-9]+/([0-9]+)$");
foreach (VRC_Trigger.TriggerEvent trigger in vrcTrigger.Triggers)
{
Match match = pattern.Match(trigger.Name);
if (!match.Success)
{
continue;
}
divisionCount = int.Parse(match.Groups[1].Value);
break;
}
foreach (var audioSource in gameObject.GetComponentsInChildren<AudioSource>(true))
{
Transform parent = audioSource.transform;
float totalSeconds = audioSource.clip.length;
var audioSourceId = audioSource.GetInstanceID();
for (var i = 1; i < divisionCount; i++)
{
var child = new GameObject(i.ToString());
child.transform.parent = parent;
var button = child.AddComponent<Button>();
SerializedObject serializedObject = new SerializedObject(button);
serializedObject.Update();
SerializedProperty calls = serializedObject.FindProperty("m_OnClick.m_PersistentCalls.m_Calls");
calls.InsertArrayElementAtIndex(0);
SerializedProperty call = calls.GetArrayElementAtIndex(0);
call.FindPropertyRelative("m_Target").objectReferenceInstanceIDValue = audioSourceId;
call.FindPropertyRelative("m_MethodName").stringValue = "set_time";
call.FindPropertyRelative("m_Mode").enumValueIndex = (int)PersistentListenerMode.Float;
call.FindPropertyRelative("m_Arguments.m_FloatArgument").floatValue
= i * totalSeconds / divisionCount;
serializedObject.ApplyModifiedProperties();
button.onClick.SetPersistentListenerState(0, UnityEventCallState.RuntimeOnly);
child.AddComponent<Animator>();
child.SetActive(false);
}
}
EditorUtility.DisplayDialog(CreateFrameByFrameAnimation.Name, "生成が完了しました。Animatorコンポーネントにコントローラーをセットしてください。", "OK");
}
[MenuItem("GameObject/UnityEditorScripts/コマ送りアニメーションの音声同期用のButtonを有効化するアニメーションを生成")]
private static void CreatePressButtonAnimation()
{
GameObject gameObject = VRChatVideoSync.GetSelectedObject();
var destinationPathes = new List<string>();
foreach (var audioSource in gameObject.GetComponentsInChildren<AudioSource>(true))
{
var buttons = audioSource.GetComponentsInChildren<Button>(true);
if (buttons.Count() == 0)
{
continue;
}
var clip = new AnimationClip();
AnimationClipSettings settings = AnimationUtility.GetAnimationClipSettings(clip);
settings.loopTime = true;
AnimationUtility.SetAnimationClipSettings(clip, settings);
var partSeconds = 0f;
foreach (Button button in buttons)
{
var index = int.Parse(button.name);
SerializedObject serializedObject = new SerializedObject(button);
serializedObject.Update();
float time = serializedObject.FindProperty("m_OnClick.m_PersistentCalls.m_Calls")
.GetArrayElementAtIndex(0).FindPropertyRelative("m_Arguments.m_FloatArgument").floatValue;
if (index == 1)
{
partSeconds = time;
}
var curve = new AnimationCurve();
curve.AddKey(new Keyframe(0, 0));
curve.AddKey(new Keyframe(time - partSeconds / 2, 1));
curve.AddKey(new Keyframe(
index == buttons.Count() ? time + partSeconds /* 総時間 */ : time + partSeconds / 2,
0
));
for (var i = 0; i < 3; i++)
{
AnimationUtility.SetKeyLeftTangentMode(curve, i, AnimationUtility.TangentMode.Constant);
}
clip.SetCurve(
CreateFrameByFrameAnimation
.RelativePathFrom(self: button.transform, root: gameObject.transform),
typeof(GameObject),
"m_IsActive",
curve
);
}
string destinationPath = AssetDatabase.GenerateUniqueAssetPath("Assets/" + audioSource.name + ".anim");
AssetDatabase.CreateAsset(clip, destinationPath);
destinationPathes.Add(destinationPath);
}
EditorUtility.DisplayDialog(
CreateFrameByFrameAnimation.Name,
"以下のファイルへ生成しました。\n" + string.Join("\n", destinationPathes.ToArray()),
"OK"
);
}
private static GameObject GetSelectedObject()
{
var gameObject = Selection.activeObject as GameObject;
if (!gameObject || !gameObject.activeInHierarchy)
{
EditorUtility.DisplayDialog(
CreateFrameByFrameAnimation.Name,
"Hierarchy 上に存在するオブジェクトを選択した状態で実行してください。",
"OK"
);
return null;
}
if (!VRChatVideoSync.ValidateAnimator(gameObject: gameObject))
{
return null;
}
return gameObject;
}
private static bool ValidateAnimator(GameObject gameObject)
{
var animatorComponent = gameObject.GetComponent<Animator>();
if (!animatorComponent)
{
EditorUtility.DisplayDialog(
CreateFrameByFrameAnimation.Name,
"選択されたオブジェクトに Animator コンポーネントが見つかりません。",
"OK"
);
return false;
}
if (!animatorComponent.runtimeAnimatorController)
{
EditorUtility.DisplayDialog(
CreateFrameByFrameAnimation.Name,
"選択されたオブジェクトの Animator コンポーネントへコントローラーがセットされていません。",
"OK"
);
return false;
}
AnimatorControllerParameter bufferingParameter = animatorComponent.parameters
.FirstOrDefault(param => param.name == VRChatVideoSync.AnimatorControllerBufferingParameterName);
if (bufferingParameter == null)
{
EditorUtility.DisplayDialog(
CreateFrameByFrameAnimation.Name,
"選択されたオブジェクトの Animator コンポーネントへセットされたコントローラーに「"
+ VRChatVideoSync.AnimatorControllerBufferingParameterName + "」という名前のパラメータが存在しません。",
"OK"
);
return false;
}
if (bufferingParameter.type != AnimatorControllerParameterType.Float)
{
EditorUtility.DisplayDialog(
CreateFrameByFrameAnimation.Name,
"アニメーターコントローラーのパラメータ「"
+ VRChatVideoSync.AnimatorControllerBufferingParameterName + "」はfloat型ではありません。",
"OK"
);
return false;
}
AnimatorControllerParameter switchingParameter = animatorComponent.parameters
.FirstOrDefault(param => param.name == VRChatVideoSync.AnimatorControllerSwitchingParameterName);
if (switchingParameter == null)
{
EditorUtility.DisplayDialog(
CreateFrameByFrameAnimation.Name,
"選択されたオブジェクトの Animator コンポーネントへセットされたコントローラーに「"
+ VRChatVideoSync.AnimatorControllerSwitchingParameterName + "」という名前のパラメータが存在しません。",
"OK"
);
return false;
}
if (switchingParameter.type != AnimatorControllerParameterType.Bool)
{
EditorUtility.DisplayDialog(
CreateFrameByFrameAnimation.Name,
"アニメーターコントローラーのパラメータ「"
+ VRChatVideoSync.AnimatorControllerSwitchingParameterName + "」はbool型ではありません。",
"OK"
);
return false;
}
return true;
}
private static VRC_Trigger GetVRCTrigger(GameObject gameObject)
{
var vrcTrigger = gameObject.GetComponent<VRC_Trigger>();
if (!vrcTrigger)
{
EditorUtility.DisplayDialog(
CreateFrameByFrameAnimation.Name,
"選択されたオブジェクトに VRC_Trigger コンポーネントが見つかりません。",
"OK"
);
return null;
}
return vrcTrigger;
}
}
#endif
internal class CountWizard : ScriptableWizard
{
[SerializeField, Range(2, 10000)]
private int 分割数 = 50;
private Action<int> callback;
internal static void PromptCount(Action<int> callback)
{
var wizard = ScriptableWizard.DisplayWizard<CountWizard>(
CreateFrameByFrameAnimation.Name,
"カスタムトリガーを生成",
"キャンセル"
);
wizard.callback = callback;
}
private void OnWizardCreate()
{
this.callback(this.分割数);
}
private void OnWizardOtherButton()
{
this.Close();
this.callback(0);
}
}
}
// SPDX-License-Identifier: MPL-2.0
// © 2021 100の人
using UnityEngine;
using UnityEngine.UI;
using VRC.SDKBase;
using VRC.Udon;
using UdonSharp;
public class QuestSyncMovieSystem : UdonSharpBehaviour
{
/// <summary>
/// 休憩時間。
/// </summary>
private readonly float RestSeconds = 4 * 60;
private readonly int TotalFrameCount = 1000;
private readonly int HorizontallyFrameCount = 24;
private readonly int CountdownTotalFrameCount = 100;
private readonly int CountdownHorizontallyFrameCount = 8;
public MeshRenderer Monitor;
public AudioSource AudioSource;
/// <summary>
/// カウントダウン → 体操1 → 体操2 → ……
/// </summary>
public Texture2D[] Textures;
/// <summary>
/// カウントダウン → ワールドBGM → 体操1 → 体操2 → ……
/// </summary>
public AudioClip[] AudioClips;
public MeshRenderer[] Buttons;
public Material PressedMaterial;
public Image RestProgressBar;
public UdonBehaviour CameraToggle;
/// <summary>
/// 現在のステータス。-2 = カウントダウン、-1 = 休憩中、0 = 開始前・終了後、1 = 体操1、2 = 体操2、……
/// </summary>
[UdonSynced]
public int Exercise = 0;
[UdonSynced]
public int ExerciseAfterCountdown;
[UdonSynced]
public float ElapsedSeconds;
private Material monitorMaterial;
private Material buttonMaterial;
private int previousExercise = int.MinValue;
/// <summary>
/// 現在のステータスの開始時間。処理中はNaN。
/// </summary>
private float startTime = float.NaN;
/// <summary>
/// 動画の総フレーム数。
/// </summary>
private int totalFrameCount;
/// <summary>
/// テクスチャの横のフレーム数。
/// </summary>
private int horizontallyFrameCount;
/// <summary>
/// 動画の再生秒数。
/// </summary>
private float totalSeconds;
/// <summary>
/// 1フレームあたりの秒数。
/// </summary>
private float framePeriodSeconds;
/// <summary>
/// テクスチャの縦のフレーム数。
/// </summary>
private int verticallyFrameCount;
/// <summary>
/// 現在のフレーム。動画再生中でない場合は -1。
/// </summary>
private int frame = -1;
public override void OnDeserialization()
{
this.SwitchExercise();
}
public void SwitchExercise()
{
if (this.Exercise == this.previousExercise)
{
return;
}
var needElapsedTimeSync = this.previousExercise == int.MinValue;
if (needElapsedTimeSync && !Networking.IsOwner(this.gameObject) && this.ElapsedSeconds == 0f)
{
return;
}
if (this.previousExercise == -1)
{
// 前回が休憩中なら
this.RestProgressBar.fillAmount = 1;
}
this.previousExercise = this.Exercise;
// カメラの表示
this.CameraToggle.SetProgramVariable(nameof(CameraToggleButton.Pressed), this.Exercise == -1);
this.CameraToggle.SendCustomEvent(nameof(CameraToggleButton.Switch));
if (this.Exercise == -1)
{
this.CameraToggle.SetProgramVariable(nameof(CameraToggleButton.Locked), true);
this.CameraToggle.SendCustomEvent(nameof(CameraToggleButton.SwitchLocked));
}
// ボタンの表示
var currentButtonIndex = this.GetCurrentButtonIndex();
for (var i = 0; i < this.Buttons.Length; i++)
{
this.Buttons[i].sharedMaterial = i == currentButtonIndex ? this.PressedMaterial : this.buttonMaterial;
}
// 音声の設定
var audioClip = this.GetCurrentAudioClip();
this.AudioSource.clip = audioClip;
this.AudioSource.loop = this.Exercise == -1 || this.Exercise == 0; // 休憩中、または開始前・終了後ならループ
this.AudioSource.time = needElapsedTimeSync ? this.ElapsedSeconds % this.GetCurrentAudioClip().length : 0f;
this.AudioSource.Play();
this.totalSeconds = this.GetCurrentTotalSeconds();
this.startTime = Time.fixedUnscaledTime - (needElapsedTimeSync ? this.ElapsedSeconds : 0f);
// 動画の設定
var texture = this.GetCurrentTexture();
if (texture != null)
{
var countdown = this.Exercise == -2;
this.totalFrameCount = countdown ? this.CountdownTotalFrameCount : this.TotalFrameCount;
this.horizontallyFrameCount = countdown ? this.CountdownHorizontallyFrameCount : this.HorizontallyFrameCount;
this.framePeriodSeconds = this.totalSeconds / this.totalFrameCount;
this.verticallyFrameCount = ((this.totalFrameCount - 1) / this.horizontallyFrameCount) + 1;
this.frame = 0;
this.monitorMaterial.SetTextureScale(
"_MainTex",
new Vector2(1f / this.horizontallyFrameCount, 1f / this.verticallyFrameCount)
);
}
else
{
this.frame = -1;
}
this.Monitor.sharedMaterial.SetTexture("_MainTex", texture);
this.Monitor.gameObject.SetActive(texture != null);
}
private void Start()
{
this.monitorMaterial = this.Monitor.sharedMaterial;
this.buttonMaterial = this.Buttons[0].sharedMaterial;
if (Networking.IsOwner(this.gameObject))
{
this.SwitchExercise();
}
}
private void Update()
{
if (float.IsNaN(this.startTime))
{
return;
}
var owner = Networking.IsOwner(this.gameObject);
var time = Time.fixedUnscaledTime;
if (time > this.startTime + this.totalSeconds)
{
this.startTime = float.NaN;
if (!owner)
{
return;
}
switch (this.Exercise)
{
case -2:
// カウントダウン
this.Exercise = this.ExerciseAfterCountdown;
break;
case -1:
// 休憩中
this.ExerciseAfterCountdown = 4;
this.Exercise = -2;
break;
case 3:
// 休憩前の体操
this.Exercise = -1;
break;
case 4:
// 最後の体操
this.Exercise = 0;
break;
default:
// その他体操中
this.Exercise++;
break;
}
this.SwitchExercise();
return;
}
// 経過時間
var elapsedSeconds = time - this.startTime;
if (owner)
{
this.ElapsedSeconds = elapsedSeconds;
}
if (this.Exercise == -1)
{
// 休憩中
this.RestProgressBar.fillAmount = 1 - elapsedSeconds / this.totalSeconds;
return;
}
if (this.frame < 0)
{
return;
}
// 現在のフレームの取得
for (var frame = 0; frame < this.totalFrameCount; frame++ )
{
if (frame * this.framePeriodSeconds > elapsedSeconds)
{
if (frame == this.frame)
{
return;
}
this.frame = frame;
break;
}
}
// フレームの設定
this.monitorMaterial.SetTextureOffset("_MainTex", new Vector2(
(float)(this.frame % this.horizontallyFrameCount) / this.horizontallyFrameCount,
- (this.frame / this.horizontallyFrameCount + 1f) / this.verticallyFrameCount
));
}
private Texture2D GetCurrentTexture()
{
int index;
switch (this.Exercise)
{
case -2:
// カウントダウン
index = 0;
break;
case -1:
// 休憩中
case 0:
// 開始前・終了後
return null;
default:
// 体操中
index = this.Exercise;
break;
}
return this.Textures[index];
}
private int GetCurrentButtonIndex()
{
var exercise = this.Exercise != -2 ? this.Exercise : this.ExerciseAfterCountdown;
switch (exercise)
{
case -1:
// 休憩中
return 0;
case 0:
// 開始前・終了後
return -1;
default:
// 体操中
return exercise;
}
}
private AudioClip GetCurrentAudioClip()
{
switch (this.Exercise)
{
case -2:
// カウントダウン
return this.AudioClips[0];
case -1:
// 休憩中
case 0:
// 開始前・終了後
return this.AudioClips[1];
default:
// 体操中
return this.AudioClips[this.Exercise + 1];
}
}
private float GetCurrentTotalSeconds()
{
switch (this.Exercise)
{
case -1:
// 休憩中
return this.RestSeconds;
case 0:
// 開始前・終了後
return float.PositiveInfinity;
default:
// カウントダウン
// 体操中
return this.GetCurrentAudioClip().length;
}
}
}
---VCI埋め込み動画再生ライブラリのサンプル。
-- @script
-- @license CC0-1.0
-- @author 100の人
------------------------------------------------------------
-- モジュールの読み込み
------------------------------------------------------------
local Video = require "video"
------------------------------------------------------------
-- 動画情報の指定
------------------------------------------------------------
local sampleVideo = Video.new({
textureInfo = "sample-video-320f-x14f-20.520000s",
materialName = "video-single",
audioSource = vci.assets.GetTransform("Cube").GetAudioSources()[1],
})
------------------------------------------------------------
-- 動画情報の指定 (複数枚構成)
------------------------------------------------------------
local sampleVideoWithMultipleTexture = Video.new({
textureInfo = "sample-video-320f-4x5-20.520000s-001",
materialName = "video-001",
})
------------------------------------------------------------
-- スクリプト本体
------------------------------------------------------------
---掴んだときに再生開始・停止するボタンのVCI SubItem名。
local START_BUTTON_NAME = "play-video"
---[SubItemの所有権&Grab状態]アイテムをGrabしたときに呼ばれる。
---@param target string @GrabされたSubItem名
function onGrab(target)
if target ~= START_BUTTON_NAME then
return
end
vci.assets.HapticPulseOnTouchingController(target, 3000, 0.1)
sampleVideo:PlayStopToggle()
sampleVideoWithMultipleTexture:PlayStopToggle()
end
---VCI埋め込み動画再生ライブラリ 6.1.0
---デモ・VCIファイル: https://virtualcast.jp/products/c866fa87eea3e0f9a015b78f94be020385bc213efddf32331c3c4b46bc0073a5
-- @script
-- @license MIT or CC-BY-4.0
-- @author 100の人
---@class VideoInfo
---@field textureInfo string @動画テクスチャの情報。
-- 「[総フレーム数]f-x[テクスチャの横のフレーム数]f-[動画の再生秒数]s」で終わる文字列。
-- 複数枚構成の場合は「[総フレーム数]f-[テクスチャの横のフレーム数]x[テクスチャの縦のフレーム数]-[動画の再生秒数]s-001」で終わる文字列。
-- 「convert-video-to-texture-for-vrchat-quest.ps1.jse」によって生成される画像ファイル名をそのまま指定することを想定しています。
---@field materialName string @動画テクスチャが設定された、拡張子を除くマテリアルファイル名。複数枚構成の場合は1枚目のマテリアルファイル名。
-- 複数枚構成の場合は「-001」で終わる文字列。
-- 複数枚構成の場合、さらにマテリアル一つごとに同名のオブジェクトを一つ作成し、同じ位置に配置しておく必要があります。
---@field audioSource ExportAudioSource @音声。再生しない場合は省略。
---@field localOnly boolean @ローカルユーザーのみで再生する場合に `true`。
---@field endCallback function @再生完了後にVCI全体の所有権を持つユーザーで呼ばれるコールバック関数。ただし、`localOnly` が `true` の場合はローカルユーザーで、`Video:PlayStopToggle()` により停止した場合は、それを実行したユーザーで呼ばれます。
---@type Video[]
local instances = { }
---@class Video
local Video = { }
---`Video` のコンストラクタ。
---@param videoInfo VideoInfo
---@return Video
function Video.new(videoInfo)
local totalFrameCount, horizontallyFrameCount, totalSeconds = string.match(
videoInfo.textureInfo,
"([1-9][0-9]*)f%-x([1-9][0-9]*)f%-([0-9]+%.?[0-9]*)s$"
)
local verticallyFrameCount
local transforms
if totalFrameCount ~= nil then
verticallyFrameCount = math.floor((totalFrameCount - 1) / horizontallyFrameCount) + 1
else
-- 複数枚構成の場合
totalFrameCount, horizontallyFrameCount, verticallyFrameCount, totalSeconds = string.match(
videoInfo.textureInfo,
"([1-9][0-9]*)f%-([1-9][0-9]*)x([1-9][0-9]*)%-([0-9]+%.?[0-9]*)s%-[0-9]+$"
)
local formatPrefix = string.gsub(videoInfo.materialName, "[0-9]+$", "")
transforms = { }
for i = 1, math.ceil(totalFrameCount / (horizontallyFrameCount * verticallyFrameCount)) do
table.insert(transforms, vci.assets.GetTransform(formatPrefix .. string.format("%03d", i)))
end
end
totalSeconds = tonumber(totalSeconds)
local instance = {
---@private
videoInfo = videoInfo,
---アイテム変数名。
---@private
stateName = "video-start-second-" .. videoInfo.textureInfo,
---動画の総フレーム数。
---@private
totalFrameCount = totalFrameCount,
---テクスチャの横のフレーム数。
---@private
horizontallyFrameCount = horizontallyFrameCount,
---動画の再生秒数。
---@private
totalSeconds = totalSeconds,
---1フレームあたりの秒数。
---@private
framePeriodSeconds = totalSeconds / totalFrameCount,
---テクスチャの縦のフレーム数。
---@private
verticallyFrameCount = verticallyFrameCount,
---複数枚構成の場合に、テクスチャが設定されたマテリアルが設定されたオブジェクトのリスト。
---@private
transforms = transforms,
---現在のフレーム。
---@private
frame = 0,
---再生を開始したユーザーの、再生開始時の `vci.me.UnscaledTime.TotalSeconds` と、ローカルとのズレ。
---@private
startTimeDiffSeconds = nil,
---`localOnly` が `true` の場合の、再生開始時の `vci.me.UnscaledTime.TotalSeconds`。
---@private
localOnlyStartTime = nil,
}
table.insert(instances, instance)
return setmetatable(instance, { __index = Video })
end
---三項演算子代わり。
---@private
---@param conditions boolean
---@param trueValue any
---@param falseValue any
---@return any
function iif(conditions, trueValue, falseValue)
if conditions then
return trueValue
else
return falseValue
end
end
---`onGrab` 関数などから呼び出すメソッド。
---動画を最初から再生します。
---@return boolean
function Video:Play()
-- 再生開始
if self.videoInfo.localOnly then
self.localOnlyStartTime = vci.me.UnscaledTime.TotalSeconds
else
vci.state.Set(self.stateName, vci.me.UnscaledTime.TotalSeconds)
end
return true
end
---`onGrab` 関数などから呼び出すメソッド。
---動画を停止中の場合は再生し `true` を返し、再生中の場合は停止し `false` を返します。
---@return boolean
function Video:PlayStopToggle()
local startTime = iif(self.videoInfo.localOnly, self.localOnlyStartTime, vci.state.Get(self.stateName))
if startTime ~= nil and startTime ~= 0 then
-- 再生中なら
-- 停止
self:Stop()
return false
else
-- 停止中なら
-- 再生開始
self:Play()
return true
end
end
---再生中の場合に、ローカルで音声をミュートにします。
---再生終了後に再度再生した場合は、ミュートになりません。
---@return boolean
function Video:MuteLocallyTemporary()
if self.videoInfo.audioSource == nil or not self.videoInfo.audioSource.IsPlaying() then
-- 音声が含まれていない、または音声を再生中でなければ
return
end
local startTime = iif(self.videoInfo.localOnly, self.localOnlyStartTime, vci.state.Get(self.stateName))
if startTime == nil or startTime == 0 then
-- 停止中なら
return
end
self.videoInfo.audioSource.Stop()
end
---再生中の場合に、ローカルで音声のミュートを解除します。
---@return boolean
function Video:UnmuteLocallyTemporary()
if self.videoInfo.audioSource == nil or self.videoInfo.audioSource.IsPlaying() then
-- 音声が含まれていない、または音声を再生中なら
return
end
local startTime = iif(self.videoInfo.localOnly, self.localOnlyStartTime, vci.state.Get(self.stateName))
if startTime == nil or startTime == 0 then
-- 停止中なら
return
end
---経過秒数。
local elapsedSeconds = vci.me.UnscaledTime.TotalSeconds - (startTime - self.startTimeDiffSeconds)
if elapsedSeconds > self.videoInfo.audioSource.GetDuration() then
-- 経過秒数が音声の長さより長ければ
return
end
self.videoInfo.audioSource.Play(1, false)
self.videoInfo.audioSource.SetTime(elapsedSeconds)
end
---再生を停止します。
---@return void
function Video:Stop()
if self.videoInfo.localOnly then
self.localOnlyStartTime = nil
else
vci.state.Set(self.stateName, 0)
end
self.startTimeDiffSeconds = nil
self.frame = 0
self:renderFrame()
if self.videoInfo.audioSource ~= nil then
self.videoInfo.audioSource[iif(self.videoInfo.localOnly, "", "_ALL_") .. "Stop"]()
end
if self.videoInfo.endCallback ~= nil then
self.videoInfo.endCallback()
end
end
---フレームの描画。
---@private
---@return void
function Video:renderFrame()
local materialName
if self.transforms then
-- 複数枚構成の場合
local currentTransformIndex
= math.ceil((self.frame + 1) / (self.horizontallyFrameCount * self.verticallyFrameCount))
for i, transform in ipairs(self.transforms) do
local current = i == currentTransformIndex
transform[iif(self.videoInfo.localOnly, "", "_ALL_") .. "SetActive"](current)
if current then
materialName = transform.GetName()
end
end
else
materialName = self.videoInfo.materialName
end
vci.assets.material[iif(self.videoInfo.localOnly, "", "_ALL_") .. "SetTextureOffset"](
materialName,
Vector2.__new(
(self.frame % self.horizontallyFrameCount) / self.horizontallyFrameCount,
- math.floor(self.frame / self.horizontallyFrameCount + 1) / self.verticallyFrameCount
)
)
end
vci.StartCoroutine(coroutine.create(function ()
while true do
for i, video in ipairs(instances) do
repeat -- continue代わりの入れ子
---`Video:PlayStopToggle()` を実行したユーザーの、実行した時点の `vci.me.UnscaledTime.TotalSeconds`。
local startTime
= iif(video.videoInfo.localOnly, video.localOnlyStartTime, vci.state.Get(video.stateName))
if startTime == nil or startTime == 0 then
-- 停止中なら
video.startTimeDiffSeconds = nil
break -- continue
end
if video.startTimeDiffSeconds == nil then
-- 再生開始直後なら
video.startTimeDiffSeconds = startTime - vci.me.UnscaledTime.TotalSeconds
if video.videoInfo.audioSource ~= nil then
video.videoInfo.audioSource.Play(1, false)
end
end
if not video.videoInfo.localOnly and not vci.assets.IsMine then
break -- continue
end
---経過秒数。
local elapsedSeconds = vci.me.UnscaledTime.TotalSeconds - (startTime - video.startTimeDiffSeconds)
if elapsedSeconds > video.totalSeconds then
-- 再生が完了したら
video:Stop()
break -- continue
end
-- 経過秒数に対応するフレームの取得
local continue = false
for f = 0, video.totalFrameCount - 1 do
if f * video.framePeriodSeconds > elapsedSeconds then
if f == video.frame then
-- 描画しているフレームと同一のフレームなら
continue = true
break
end
video.frame = f
break
end
end
if continue then
break -- continue
end
-- フレームの描画
video:renderFrame()
coroutine.yield()
until false
end
coroutine.yield()
end
end))
return Video
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment