Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
■『CreateFrameByFrameAnimation.cs』 VRChatのQuest版における動画再生用のエディタ拡張。テクスチャをHierarchey上のメッシュオブジェクトに設定しておき、そのオブジェクトのコンテクストメニューから生成します。 ■『VCI埋め込み動画再生ライブラリ』バーチャルキャストのアイテム・背景に動画を埋め込めるようにするLuaスクリプト片。 ■『convert-video-to-texture-for-vrchat-quest.ps1.jse』MP4などの動画から、一定間隔ごとにUVを一瞬でズラすことができるシステム向けのテクスチャに変換するスクリプトです。 https://twitter.com/esperecyan/status/115569878…
#@~^AQAAAA==~IAAAAA==^#~@ function toPSString(str) { return "'" + str.replace(/%/g, '"%"').replace(/'/g, "''") + "'"; } /* -*- mode: powershell;-*-
<#*/ var command = 'param($Path, $FrameCount, $verticallyResolution)'
+ '; $_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
動画をVRChatのQuest版で再生できるテクスチャに変換するスクリプトです。
.DESCRIPTION
あらかじめパスの通った場所にffmpegをインストールしておく必要があります。
参照:
100の人さんのツイート: “VRChat Quest版におけるパフォーマンスランクをMediumに抑えてアニラボのロゴを再生。板ポリでいいのでポリゴンの形状を気にする必要がなくやポリゴン数制限にも優しい。途中いろいろ教えてくれたFuriaさんに感謝! https://t.co/E6kryJnzpw ↑アニメーションファイル生成用のUnityエディタ拡張を公開… https://t.co/b0ukdENkxs”
<https://twitter.com/esperecyan/status/1155698784380178433>
Licence: MPL-2.0 <https://www.mozilla.org/MPL/2.0/>
Author: 100の人
配布元: <https://gist.github.com/esperecyan>
.EXAMPLE
convert-video-to-texture-for-vrchat-quest.ps1.jse -Path test.mp4 -FrameCount 1000
.PARAMETER Path
動画ファイルのパス。
.PARAMETER FrameCount
出力時のフレーム数。
#>
using namespace System.IO
using namespace System.Drawing
using namespace System.Windows.Forms
Set-StrictMode -Version Latest; $ErrorActionPreference = 'Stop';
$PSCommandPath = $_PSCommandPath; $PSScriptRoot = Split-Path $PSCommandPath -Parent
Add-Type -AssemblyName @('System.Drawing', 'System.Windows.Forms')
$TEXTURE_SIZE = 8192
$Path = Resolve-Path $Path
$ErrorActionPreference = 'SilentlyContinue'
$videoInfo = (ffprobe -show_streams -print_format json $Path 2>$null | ConvertFrom-Json).streams[0]
$ErrorActionPreference = 'Stop'
$side = [Math]::Sqrt($videoInfo.Width * $videoInfo.Height * $FrameCount)
$horizontallyFrameCount = [Math]::Ceiling($side / $videoInfo.Width)
$verticallyFrameCount = [Math]::Ceiling($FrameCount / $horizontallyFrameCount)
$magnification = [Math]::Min(
1.0,
$TEXTURE_SIZE / [Math]::Max($videoInfo.Width * $horizontallyFrameCount, $videoInfo.Height * $verticallyFrameCount)
)
$destinationRect = New-Object Rectangle(0, 0, ($videoInfo.Width * $magnification), ($videoInfo.Height * $magnification))
$destinationBitmap = New-Object Bitmap(
[int]($destinationRect.Width * $horizontallyFrameCount),
[int]($destinationRect.Height * $verticallyFrameCount)
)
$graphics = [Graphics]::FromImage($destinationBitmap)
$temporaryFolderPath = Join-Path ([Path]::GetTempPath()) ([Path]::GetRandomFileName())
New-Item $temporaryFolderPath -ItemType Directory
ffmpeg -i $Path -r $($FrameCount / $videoInfo.duration) -f image2 (Join-Path $temporaryFolderPath '%04d.png')
foreach ($file in (Get-ChildItem $temporaryFolderPath)) {
$image = [Image]::FromFile($file.FullName)
$i = -1 + $file.Basename
$destinationRect.X = ($i % $horizontallyFrameCount) * $destinationRect.Width
$destinationRect.Y = [Math]::Floor($i / $horizontallyFrameCount) * $destinationRect.Height
$graphics.DrawImage($image, $destinationRect)
$image.Dispose()
}
Remove-Item $temporaryFolderPath -Recurse
$graphics.Dispose()
$destinationPath = Join-Path (Split-Path $Path -Parent) ([Path]::GetFileNameWithoutExtension($Path) `
+ "-$($FrameCount)f-x$($horizontallyFrameCount)f-$($videoInfo.duration)s.png")
$destinationBitmap.Save($destinationPath)
$destinationBitmap.Dispose()
[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;
using VRCSDK2;
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 2017.4.28f1
/// ライセンス: 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");
}
}
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;
}
}
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 MIT or CC-BY-4.0
-- @author 100の人
------------------------------------------------------------
-- モジュールの読み込み
------------------------------------------------------------
local Video = require "video"
------------------------------------------------------------
-- 動画情報の指定
------------------------------------------------------------
local sampleVideo = Video.new({
textureInfo = "sample-video-320f-x14f-20.520000s",
materialName = "video",
audioName = "sample-audio",
})
------------------------------------------------------------
-- スクリプト本体
------------------------------------------------------------
---掴んだときに再生開始・停止するボタンの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()
end
---VCI埋め込み動画再生ライブラリ 3.0.0
---デモ・VCIファイル: https://seed.online/products/c866fa87eea3e0f9a015b78f94be020385bc213efddf32331c3c4b46bc0073a5
-- @script
-- @license MIT or CC-BY-4.0
-- @author 100の人
---@class VideoInfo
---@field textureInfo string @動画用テクスチャの情報。
-- 「[総フレーム数]f-x[テクスチャの横のフレーム数]f-[動画の再生秒数]s」で終わる文字列。
-- 「convert-video-to-texture-for-vrchat-quest.ps1.jse」によって生成される画像ファイル名をコピペすることを想定しています。
-- テクスチャ (画像ファイル) 名と同じである必要はありません。
---@field materialName string @動画用テクスチャが設定されたマテリアル名。
---@field audioName string @音声ファイル名。再生しない場合は省略。
---@field endCallback function @再生完了後にVCI全体の所有権を持つユーザーで呼ばれるコールバック関数。ただし、`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$")
totalSeconds = tonumber(totalSeconds)
local instance = {
---@private
videoInfo = videoInfo,
---アイテム変数名。
---@private
stateName = "video-start-second-" .. videoInfo.materialName,
---動画の総フレーム数。
---@private
totalFrameCount = totalFrameCount,
---テクスチャの横のフレーム数。
---@private
horizontallyFrameCount = horizontallyFrameCount,
---動画の再生秒数。
---@private
totalSeconds = totalSeconds,
---1フレームあたりの秒数。
---@private
framePeriodSeconds = totalSeconds / totalFrameCount,
---テクスチャの縦のフレーム数。
---@private
verticallyFrameCount = math.floor((totalFrameCount - 1) / horizontallyFrameCount) + 1,
---現在のフレーム。
---@private
frame = 0,
---再生を開始したユーザーの、再生開始時の `vci.me.UnscaledTime.TotalSeconds` と、ローカルとのズレ。
---@private
startTimeDiffSeconds = nil,
}
table.insert(instances, instance)
return setmetatable(instance, { __index = Video })
end
---`onGrab` 関数などから呼び出すメソッド。
---動画を最初から再生します。
---@return boolean
function Video:Play()
-- 再生開始
vci.state.Set(self.stateName, vci.me.UnscaledTime.TotalSeconds)
return true
end
---`onGrab` 関数などから呼び出すメソッド。
---動画を停止中の場合は再生し `true` を返し、再生中の場合は停止し `false` を返します。
---@return boolean
function Video:PlayStopToggle()
local startTime = vci.state.Get(self.stateName)
if startTime ~= nil and startTime ~= 0 then
-- 再生中なら
-- 停止
self:stop()
return false
else
-- 停止中なら
-- 再生開始
vci.state.Set(self.stateName, vci.me.UnscaledTime.TotalSeconds)
return true
end
end
---再生を停止します。
---@private
---@return void
function Video:stop()
vci.state.Set(self.stateName, 0)
self.startTimeDiffSeconds = nil
vci.assets._ALL_SetMaterialTextureOffsetFromName(self.videoInfo.materialName, Vector2.zero)
if self.videoInfo.audioName ~= nil and #self.videoInfo.audioName > 0 then
vci.assets._ALL_StopAudioFromName(self.videoInfo.audioName)
end
if self.videoInfo.endCallback ~= nil then
self.videoInfo.endCallback()
end
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 = 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.audioName ~= nil and #video.videoInfo.audioName > 0 then
vci.assets.PlayAudioFromName(video.videoInfo.audioName)
end
end
if 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
-- フレームの描画
vci.assets._ALL_SetMaterialTextureOffsetFromName(video.videoInfo.materialName, Vector2.__new(
(video.frame % video.horizontallyFrameCount) / video.horizontallyFrameCount,
- math.floor(video.frame / video.horizontallyFrameCount) / video.verticallyFrameCount
))
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