diff --git a/voicevox-remotion-template/src/data/pizza-kiln/timing.ts b/voicevox-remotion-template/src/data/pizza-kiln/timing.ts index b833636..d6ade97 100644 --- a/voicevox-remotion-template/src/data/pizza-kiln/timing.ts +++ b/voicevox-remotion-template/src/data/pizza-kiln/timing.ts @@ -82,6 +82,14 @@ return durationForVideo(event, fps); } + if ( + event.type === "clearStill" || + event.type === "clearVideo" || + event.type === "standeePosition" + ) { + return 0; + } + if (event.durationSeconds && Number.isFinite(event.durationSeconds)) { return Math.max(1, Math.ceil(event.durationSeconds * fps)); } diff --git a/voicevox-remotion-template/src/data/pizza-oven-project-01/timing.ts b/voicevox-remotion-template/src/data/pizza-oven-project-01/timing.ts index a9ce930..d1bd36e 100644 --- a/voicevox-remotion-template/src/data/pizza-oven-project-01/timing.ts +++ b/voicevox-remotion-template/src/data/pizza-oven-project-01/timing.ts @@ -3,6 +3,7 @@ defineVQScenarioAssetWorkflow, totalVQChronologicalScenarioDurationInFrames, } from "../../lib/VQRemotionLib/scenario"; +import {doesVQTimelineEventAdvanceTimeline} from "../../lib/VQRemotionLib/timeline"; import {timeline, type SpeechEvent, type TimelineEvent} from "./script"; import voicevoxManifest from "./voicevox-manifest.json"; @@ -58,6 +59,14 @@ return durationForSpeech(event, fps); } + if ( + event.type === "clearStill" || + event.type === "clearVideo" || + event.type === "standeePosition" + ) { + return 0; + } + const durationSeconds = event.durationSeconds ?? PIZZA_OVEN_PROJECT_01_DEFAULT_STILL_SECONDS; return Math.max(1, Math.ceil(durationSeconds * fps)); @@ -83,6 +92,7 @@ timeline, gapFrames: PIZZA_OVEN_PROJECT_01_GAP_FRAMES, durationForEvent: durationForTimelineEvent, + doesEventAdvanceTimeline: doesVQTimelineEventAdvanceTimeline, assetWorkflow: pizzaOvenProject01AssetWorkflow, }); diff --git a/voicevox-remotion-template/src/data/yukkuri-composition/timing.ts b/voicevox-remotion-template/src/data/yukkuri-composition/timing.ts index 7b98e97..bf874b9 100644 --- a/voicevox-remotion-template/src/data/yukkuri-composition/timing.ts +++ b/voicevox-remotion-template/src/data/yukkuri-composition/timing.ts @@ -46,6 +46,14 @@ return durationForSpeech(event, fps); } + if ( + event.type === "clearStill" || + event.type === "clearVideo" || + event.type === "standeePosition" + ) { + return 0; + } + const durationSeconds = event.durationSeconds ?? DEFAULT_SHOW_SECONDS; return Math.max(1, Math.ceil(durationSeconds * fps)); }; diff --git a/voicevox-remotion-template/src/lib/VQRemotionLib/timeline.ts b/voicevox-remotion-template/src/lib/VQRemotionLib/timeline.ts index 2242f02..ad14c65 100644 --- a/voicevox-remotion-template/src/lib/VQRemotionLib/timeline.ts +++ b/voicevox-remotion-template/src/lib/VQRemotionLib/timeline.ts @@ -38,6 +38,29 @@ opacity?: number; }>; +export type VQClearStillEvent = Readonly<{ + type: "clearStill"; + id: string; +}>; + +export type VQClearVideoEvent = Readonly<{ + type: "clearVideo"; + id: string; + videoId?: string; +}>; + +export type VQStandeePosition = "stage" | "corner"; + +export type VQStandeePositionEvent< + CharacterId extends string = string, + Position extends string = VQStandeePosition +> = Readonly<{ + type: "standeePosition"; + id: string; + character: CharacterId; + position: Position; +}>; + export type VQWaitEvent = Readonly<{ type: "wait"; id: string; @@ -90,6 +113,9 @@ > = | VQSpeechEvent | VQStillEvent + | VQClearStillEvent + | VQClearVideoEvent + | VQStandeePositionEvent | VQWaitEvent | VQVideoEvent | ExtraEvent; @@ -101,6 +127,9 @@ > = | VQSpeechEvent | VQStillEvent + | VQClearStillEvent + | VQClearVideoEvent + | VQStandeePositionEvent | VQWaitEvent | VQWaitEventInput | VQVideoEvent @@ -160,6 +189,43 @@ ...options, }); +// 用途: 現在表示中の静止画を消すタイムラインイベントとして定義する。 +// 使用方法: script.ts の timeline 内で clearStill("id") として呼び出す。 +// オプションや引数詳細: id はタイムライン内で一意なイベントIDを指定する。 +export const clearStill = (id: string): VQClearStillEvent => ({ + type: "clearStill", + id, +}); + +// 用途: 現在再生中の動画を消すタイムラインイベントとして定義する。 +// 使用方法: script.ts の timeline 内で clearVideo("id", "video-id") として呼び出す。 +// オプションや引数詳細: videoId を省略すると対応側の実装に応じて再生中の動画全体を停止対象にできる。 +export const clearVideo = ( + id: string, + videoId?: string +): VQClearVideoEvent => ({ + type: "clearVideo", + id, + ...(videoId === undefined ? {} : {videoId}), +}); + +// 用途: キャラクター立ち絵の表示位置を切り替えるタイムラインイベントとして定義する。 +// 使用方法: script.ts の timeline 内で standeePosition("id", "sayo", "corner") として呼び出す。 +// オプションや引数詳細: position は既定レイアウトの "stage" / "corner"、または呼び出し側で扱う独自位置名を指定する。 +export const standeePosition = < + CharacterId extends string, + Position extends string = VQStandeePosition +>( + id: string, + character: CharacterId, + position: Position +): VQStandeePositionEvent => ({ + type: "standeePosition", + id, + character, + position, +}); + // 用途: 何も発話しない待機時間をタイムラインイベントとして定義する。 // 使用方法: script.ts の timeline 内で wait(1) のように秒数だけを指定し、defineVQTimelineでIDを確定する。 // オプションや引数詳細: durationSeconds は待機秒数で、IDはtimeline単位で wait-001 から自動採番される。 @@ -188,9 +254,12 @@ export const doesVQTimelineEventAdvanceTimeline = ( event: Readonly<{type: string; placement?: string}> ) => - event.type !== "video" || - event.placement === undefined || - event.placement === "blocking"; + event.type !== "clearStill" && + event.type !== "clearVideo" && + event.type !== "standeePosition" && + (event.type !== "video" || + event.placement === undefined || + event.placement === "blocking"); // 用途: waitイベントの連番からタイムライン内IDを作る。 // 使用方法: defineVQTimeline内で未解決waitイベントを正規化するときに呼び出す。 diff --git a/voicevox-remotion-template/src/pizzaOvenProject01.ts b/voicevox-remotion-template/src/pizzaOvenProject01.ts index 8937ba2..135e2b5 100644 --- a/voicevox-remotion-template/src/pizzaOvenProject01.ts +++ b/voicevox-remotion-template/src/pizzaOvenProject01.ts @@ -24,9 +24,13 @@ VQSpeechOverlay, VQStageCornerStandee, VQStillBackground, + VQTimelineVideo, VQWarmGradientBackground, + type VQScheduledScenarioSegment, type VQStageCornerStandeeLayouts, + type VQStageCornerStandeeMode, type VQStillEvent, + type VQVideoEvent, vqDefaultStageCornerStandeeLayouts, } from "./lib/VQRemotionLib"; import {getMouthForSpeechFrame} from "./lipsync/manifest"; @@ -49,9 +53,31 @@ extrapolateRight: "clamp", } as const; +type ScheduledTimelineEvent = VQScheduledScenarioSegment; + +const videoSequenceDuration = ( + scheduledVideo: ScheduledTimelineEvent & Readonly<{event: VQVideoEvent}>, + scheduledEvents: readonly ScheduledTimelineEvent[] +) => { + const clearFrame = scheduledEvents.find( + (scheduledEvent) => + scheduledEvent.from >= scheduledVideo.from && + scheduledEvent.event.type === "clearVideo" && + (scheduledEvent.event.videoId === undefined || + scheduledEvent.event.videoId === scheduledVideo.event.id) + )?.from; + + return clearFrame === undefined + ? scheduledVideo.durationInFrames + : Math.min( + scheduledVideo.durationInFrames, + Math.max(0, clearFrame - scheduledVideo.from) + ); +}; + const SayoStandee: React.FC< Readonly<{ - mode: "stage" | "corner"; + mode: VQStageCornerStandeeMode; frame: number; fps: number; activeSpeech?: SpeechEvent; @@ -83,7 +109,9 @@ event, }) => { if (event.type !== "say") { - return null; + return event.type === "video" + ? React.createElement(VQTimelineVideo, {video: event}) + : null; } const character = characters[event.character]; @@ -123,26 +151,71 @@ ? activeSegment.event : undefined; const activeStill = scheduledEvents.reduce( - (currentStill, scheduledEvent) => - scheduledEvent.from <= frame && scheduledEvent.event.type === "still" - ? scheduledEvent.event - : currentStill, + (currentStill, scheduledEvent) => { + if (scheduledEvent.from > frame) { + return currentStill; + } + + if (scheduledEvent.event.type === "still") { + return scheduledEvent.event; + } + + if (scheduledEvent.event.type === "clearStill") { + return undefined; + } + + return currentStill; + }, undefined ); + const activeStandeePosition = + scheduledEvents.reduce( + (currentPosition, scheduledEvent) => { + if (scheduledEvent.from > frame) { + return currentPosition; + } + + if ( + scheduledEvent.event.type === "standeePosition" && + scheduledEvent.event.character === "sayo" + ) { + return scheduledEvent.event.position; + } + + return currentPosition; + }, + "stage" + ); const speakingLocalFrame = activeSegment && activeSpeech ? frame - activeSegment.from : 0; - const sequences = scheduledEvents.map((scheduledEvent) => - React.createElement( - Sequence, - { - key: scheduledEvent.event.id, - from: scheduledEvent.from, - durationInFrames: scheduledEvent.durationInFrames, - premountFor: Math.min(fps, scheduledEvent.from), - }, - React.createElement(TimelineOverlay, {event: scheduledEvent.event}) - ) - ); + const sequences = scheduledEvents + .map((scheduledEvent) => ({ + scheduledEvent, + durationInFrames: + scheduledEvent.event.type === "video" + ? videoSequenceDuration( + scheduledEvent as ScheduledTimelineEvent & Readonly<{ + event: VQVideoEvent; + }>, + scheduledEvents + ) + : scheduledEvent.durationInFrames, + })) + .filter(({durationInFrames}) => durationInFrames > 0) + .map((scheduledEvent) => + React.createElement( + Sequence, + { + key: scheduledEvent.scheduledEvent.event.id, + from: scheduledEvent.scheduledEvent.from, + durationInFrames: scheduledEvent.durationInFrames, + premountFor: Math.min(fps, scheduledEvent.scheduledEvent.from), + }, + React.createElement(TimelineOverlay, { + event: scheduledEvent.scheduledEvent.event, + }) + ) + ); return React.createElement( AbsoluteFill, @@ -156,7 +229,7 @@ React.createElement(VQWarmGradientBackground, null), React.createElement(VQStillBackground, {still: activeStill}), React.createElement(SayoStandee, { - mode: activeStill ? "corner" : "stage", + mode: activeStandeePosition, frame, fps, activeSpeech,