diff --git a/voicevox-remotion-template/public/image/still/nc238325_living_day.jpg b/voicevox-remotion-template/public/image/still/nc238325_living_day.jpg new file mode 100644 index 0000000..e32f9d9 --- /dev/null +++ b/voicevox-remotion-template/public/image/still/nc238325_living_day.jpg Binary files differ diff --git a/voicevox-remotion-template/public/image/still/nc238325_living_day.jpg.txt b/voicevox-remotion-template/public/image/still/nc238325_living_day.jpg.txt new file mode 100644 index 0000000..5ba97df --- /dev/null +++ b/voicevox-remotion-template/public/image/still/nc238325_living_day.jpg.txt @@ -0,0 +1,27 @@ +リビング(日中) ライセンス・使用条件メモ + +確認日: 2026-05-28 + +出典: +https://commons.nicovideo.jp/works/nc238325 + +素材情報: +- ニコニ・コモンズID: nc238325 +- 作品名: リビング(日中) +- 作者: みんちり +- 種別: 背景・壁紙 / 画像 +- ファイル形式: jpg + +利用条件の要約: +- 利用範囲: インターネット全般 +- 親作品登録: 任意 +- クレジット表記: 任意 +- 個人の無料公開・収益化: OK +- 個人のその他収益化: OK +- 法人の無料公開・収益化: OK +- 法人のその他収益化: OK +- 個別条件: 素材としての再配布・販売はご遠慮ください。 + +注意: +- このメモは制作管理用の要約であり、法律助言ではない。 +- 公開・配布前には、出典ページおよびニコニ・コモンズ上の最新条件を確認すること。 diff --git a/voicevox-remotion-template/src/data/zundamon-jiron/script.ts b/voicevox-remotion-template/src/data/zundamon-jiron/script.ts index d673e62..a0def4e 100644 --- a/voicevox-remotion-template/src/data/zundamon-jiron/script.ts +++ b/voicevox-remotion-template/src/data/zundamon-jiron/script.ts @@ -3,10 +3,13 @@ wait, defineVQTimeline, say, + still, standeeFacingDirection, + standeeShake, standeeVerticalOffset, type VQCustomTimelineEvent, type VQStandeeFacingDirectionEvent, + type VQStandeeShakeEvent, type VQStandeeVerticalOffsetEvent, type VQSpeechEvent, type VQTimelineEvent, @@ -40,6 +43,7 @@ VoicevoxVoice, | ShowEvent | VQStandeeFacingDirectionEvent + | VQStandeeShakeEvent | VQStandeeVerticalOffsetEvent >; export type TimelineInputEvent = VQTimelineInputEvent< @@ -47,6 +51,7 @@ VoicevoxVoice, | ShowEvent | VQStandeeFacingDirectionEvent + | VQStandeeShakeEvent | VQStandeeVerticalOffsetEvent >; @@ -63,6 +68,9 @@ }); export const timeline = defineVQTimeline([ + still("zundamon-jiron-background-001", "image/still/nc238325_living_day.jpg", { + fit: "cover", + }), audio("zundamon-jiron-bgm-001", "audio/common_bgm/saturn-3-music-rockabye-baby-music-box-lullaby-loopable-527146.mp3", { playback: "loop", volume: 0.2, @@ -96,11 +104,18 @@ durationSeconds: 10.0, } ), + standeeShake("zundamon-jiron-zunda-shake-001", "zundamon", { + durationSeconds: 2.0, + amplitudeX: 9, + amplitudeY: 6, + frequencyHz: 16, + }), wait(2.0), say("zundamon-jiron-zunda-010", "zundamon", "……丁度いま、清潔じゃなくなったのだ。"), say("zundamon-jiron-sayo-006", "sayo", "そうだね…。"), say("zundamon-jiron-sayo-007", "sayo", "(……でも、漏らさないで使う限りはずんだもんが言うとおりなのかな?)"), say("zundamon-jiron-zunda-011", "zundamon", "こまめに替えれば大丈夫なのだ!\nなので、小夜もおむつスタイルを試してみるといいのだ!"), say("zundamon-jiron-sayo-008", "sayo", "考えておくね。\nずんだもんは早くおむつ替えてね。"), - say("zundamon-jiron-zunda-012", "zundamon", "そうするのだ!") + say("zundamon-jiron-zunda-012", "zundamon", "そうするのだ!"), + wait(4.0), ] satisfies readonly TimelineInputEvent[]); diff --git a/voicevox-remotion-template/src/data/zundamon-jiron/timing.ts b/voicevox-remotion-template/src/data/zundamon-jiron/timing.ts index 8916c38..482bdfd 100644 --- a/voicevox-remotion-template/src/data/zundamon-jiron/timing.ts +++ b/voicevox-remotion-template/src/data/zundamon-jiron/timing.ts @@ -64,7 +64,20 @@ return 0; } + if (event.type === "standeeShake") { + if (event.durationFrames && Number.isFinite(event.durationFrames)) { + return Math.max(1, Math.ceil(event.durationFrames)); + } + + if (event.durationSeconds && Number.isFinite(event.durationSeconds)) { + return Math.max(1, Math.ceil(event.durationSeconds * fps)); + } + + return 0; + } + if ( + event.type === "still" || event.type === "clearStill" || event.type === "clearVideo" || event.type === "standeePosition" || @@ -82,9 +95,12 @@ }; const doesTimelineEventAdvanceTimeline = (event: TimelineEvent) => + event.type !== "still" && + event.type !== "clearStill" && event.type !== "audio" && event.type !== "standeeFacingDirection" && - event.type !== "standeeVerticalOffset"; + event.type !== "standeeVerticalOffset" && + event.type !== "standeeShake"; export const ZundamonJironAssetWorkflow: VQScenarioAssetWorkflow = defineVQScenarioAssetWorkflow({ diff --git a/voicevox-remotion-template/src/data/zundamon-jiron/voicevox-manifest.json b/voicevox-remotion-template/src/data/zundamon-jiron/voicevox-manifest.json index 5bfefce..5289586 100644 --- a/voicevox-remotion-template/src/data/zundamon-jiron/voicevox-manifest.json +++ b/voicevox-remotion-template/src/data/zundamon-jiron/voicevox-manifest.json @@ -169,5 +169,14 @@ "speakerId": 46, "file": "audio/zundamon-jiron/lines/zundamon-jiron-sayo-008.wav", "durationSeconds": 4.053333333333334 + }, + { + "id": "zundamon-jiron-zunda-012", + "character": "zundamon", + "speakerName": "ずんだもん", + "styleName": "ノーマル", + "speakerId": 3, + "file": "audio/zundamon-jiron/lines/zundamon-jiron-zunda-012.wav", + "durationSeconds": 1.152 } ] diff --git a/voicevox-remotion-template/src/generated/lipsync/manifest.json b/voicevox-remotion-template/src/generated/lipsync/manifest.json index 95c86e0..ba83205 100644 --- a/voicevox-remotion-template/src/generated/lipsync/manifest.json +++ b/voicevox-remotion-template/src/generated/lipsync/manifest.json @@ -12015,6 +12015,65 @@ "source": "X" } ] + }, + "zundamon-jiron-zunda-012": { + "version": 1, + "source": { + "audio": "audio/zundamon-jiron/lines/zundamon-jiron-zunda-012.wav", + "engine": "rhubarb-lip-sync", + "recognizer": "phonetic" + }, + "duration": 1.15, + "cues": [ + { + "start": 0, + "end": 0.11, + "mouth": "rest", + "source": "X" + }, + { + "start": 0.11, + "end": 0.25, + "mouth": "i", + "source": "B" + }, + { + "start": 0.25, + "end": 0.39, + "mouth": "u", + "source": "F" + }, + { + "start": 0.39, + "end": 0.67, + "mouth": "i", + "source": "B" + }, + { + "start": 0.67, + "end": 0.74, + "mouth": "e", + "source": "C" + }, + { + "start": 0.74, + "end": 1, + "mouth": "closed", + "source": "A" + }, + { + "start": 1, + "end": 1.08, + "mouth": "i", + "source": "B" + }, + { + "start": 1.08, + "end": 1.15, + "mouth": "rest", + "source": "X" + } + ] } } } diff --git a/voicevox-remotion-template/src/lib/VQRemotionLib/components/VQCharacterAvatar.tsx b/voicevox-remotion-template/src/lib/VQRemotionLib/components/VQCharacterAvatar.tsx index 9eb4a9c..ec66a31 100644 --- a/voicevox-remotion-template/src/lib/VQRemotionLib/components/VQCharacterAvatar.tsx +++ b/voicevox-remotion-template/src/lib/VQRemotionLib/components/VQCharacterAvatar.tsx @@ -23,6 +23,7 @@ fontFamily?: string; imageFilter?: string; imageFlipX?: boolean; + imageTranslateX?: number; imageTranslateY?: number; }>; @@ -97,6 +98,7 @@ fontFamily = "system-ui, sans-serif", imageFilter = "drop-shadow(0 18px 40px rgba(31, 42, 68, 0.22))", imageFlipX, + imageTranslateX = 0, imageTranslateY, }: VQCharacterAvatarProps) => { const {avatar} = character; @@ -122,7 +124,7 @@ const imageScaleX = (imageFlipX ?? avatar.imageLayout?.flipX) ? -1 : 1; const imageWidth = avatar.imageLayout?.width ?? 320; const imageScale = imageScaleForLayout(avatar.imageLayout, imageWidth); - const imageTransform = `translateY(${resolvedImageTranslateY}px) scale(${imageScale}) scaleX(${imageScaleX})`; + const imageTransform = `translate(${imageTranslateX}px, ${resolvedImageTranslateY}px) scale(${imageScale}) scaleX(${imageScaleX})`; const nameplatePosition = avatar.nameplatePosition ?? "bottom"; const showNameplate = nameplatePosition !== "none"; const mouth = mouthForAvatar({ diff --git a/voicevox-remotion-template/src/lib/VQRemotionLib/components/VQCharacterStage.tsx b/voicevox-remotion-template/src/lib/VQRemotionLib/components/VQCharacterStage.tsx index dfe0d28..f18c2cb 100644 --- a/voicevox-remotion-template/src/lib/VQRemotionLib/components/VQCharacterStage.tsx +++ b/voicevox-remotion-template/src/lib/VQRemotionLib/components/VQCharacterStage.tsx @@ -17,6 +17,7 @@ stageStyle?: React.CSSProperties; avatarImageFilter?: string; avatarFlipXByCharacter?: Partial>; + avatarTranslateXByCharacter?: Partial>; avatarTranslateYByCharacter?: Partial>; }>; @@ -34,6 +35,7 @@ stageStyle, avatarImageFilter, avatarFlipXByCharacter, + avatarTranslateXByCharacter, avatarTranslateYByCharacter, }: VQCharacterStageProps) => { const hasMultipleCharacters = visibleCharacters.length > 1; @@ -67,6 +69,7 @@ fontFamily={fontFamily} imageFilter={avatarImageFilter} imageFlipX={avatarFlipXByCharacter?.[characterId]} + imageTranslateX={avatarTranslateXByCharacter?.[characterId]} imageTranslateY={avatarTranslateYByCharacter?.[characterId]} /> ))} diff --git a/voicevox-remotion-template/src/lib/VQRemotionLib/timeline.ts b/voicevox-remotion-template/src/lib/VQRemotionLib/timeline.ts index 7fb95fa..1bdf390 100644 --- a/voicevox-remotion-template/src/lib/VQRemotionLib/timeline.ts +++ b/voicevox-remotion-template/src/lib/VQRemotionLib/timeline.ts @@ -81,6 +81,18 @@ translateY: number; }>; +export type VQStandeeShakeEvent = + Readonly<{ + type: "standeeShake"; + id: string; + character: CharacterId; + durationFrames?: number; + durationSeconds?: number; + amplitudeX?: number; + amplitudeY?: number; + frequencyHz?: number; + }>; + export type VQWaitEvent = Readonly<{ type: "wait"; id: string; @@ -153,6 +165,7 @@ | VQStandeePositionEvent | VQStandeeFacingDirectionEvent | VQStandeeVerticalOffsetEvent + | VQStandeeShakeEvent | VQWaitEvent | VQAudioEvent | VQVideoEvent @@ -170,6 +183,7 @@ | VQStandeePositionEvent | VQStandeeFacingDirectionEvent | VQStandeeVerticalOffsetEvent + | VQStandeeShakeEvent | VQWaitEvent | VQWaitEventInput | VQAudioEvent @@ -295,6 +309,20 @@ translateY, }); +// 用途: 指定したキャラクター立ち絵を一定時間x/y方向へ振動させるタイムラインイベントとして定義する。 +// 使用方法: script.ts の timeline 内で standeeShake("id", "zundamon", {durationSeconds: 2}) として呼び出す。 +// オプションや引数詳細: amplitudeX/Y はpx単位の振れ幅、frequencyHz は1秒あたりの揺れ回数を指定する。 +export const standeeShake = ( + id: string, + character: CharacterId, + options: Omit, "type" | "id" | "character"> +): VQStandeeShakeEvent => ({ + type: "standeeShake", + id, + character, + ...options, +}); + // 用途: 何も発話しない待機時間をタイムラインイベントとして定義する。 // 使用方法: script.ts の timeline 内で wait(1) のように秒数だけを指定し、defineVQTimelineでIDを確定する。 // オプションや引数詳細: durationSeconds は待機秒数で、IDはtimeline単位で wait-001 から自動採番される。 @@ -342,6 +370,7 @@ event.type !== "standeePosition" && event.type !== "standeeFacingDirection" && event.type !== "standeeVerticalOffset" && + event.type !== "standeeShake" && event.type !== "audio" && (event.type !== "video" || event.placement === undefined || diff --git a/voicevox-remotion-template/src/zundamon-jiron.tsx b/voicevox-remotion-template/src/zundamon-jiron.tsx index b99c062..9cce6ba 100644 --- a/voicevox-remotion-template/src/zundamon-jiron.tsx +++ b/voicevox-remotion-template/src/zundamon-jiron.tsx @@ -26,10 +26,12 @@ VQCaptionOverlay, VQCharacterStage, VQSpeechOverlay, + VQStillBackground, VQTimelineAudio, VQWarmGradientBackground, type VQAudioEvent, type VQMouthResolver, + type VQStillEvent, } from "./lib/VQRemotionLib"; import {getMouthForSpeechFrame} from "./lipsync/manifest"; @@ -62,9 +64,12 @@ "character" in event ? event.character : undefined; const doesEventAdvanceTimeline = (event: TimelineEvent) => + event.type !== "still" && + event.type !== "clearStill" && event.type !== "audio" && event.type !== "standeeFacingDirection" && - event.type !== "standeeVerticalOffset"; + event.type !== "standeeVerticalOffset" && + event.type !== "standeeShake"; const audioSequenceDuration = ( scheduledAudio: ScheduledTimelineEvent & Readonly<{event: VQAudioEvent}>, @@ -152,6 +157,41 @@ return activeSegment; }; +// 用途: 現在フレームで有効な立ち絵振動イベントをx/yオフセットへ変換する。 +// 使用方法: Composition 内で scheduledEvents と frame を渡し、VQCharacterStage の translate 指定へ合成する。 +// オプションや引数詳細: amplitudeX/Y と frequencyHz はイベントごとの値を優先し、省略時は控えめな震えにする。 +const standeeShakeOffsetsForFrame = ( + scheduledEvents: ScheduledTimelineEvent[], + frame: number, + fps: number +) => { + const offsets: Partial>> = + {}; + + for (const scheduledEvent of scheduledEvents) { + const {event} = scheduledEvent; + if (event.type !== "standeeShake") { + continue; + } + + const localFrame = frame - scheduledEvent.from; + if (localFrame < 0 || localFrame >= scheduledEvent.durationInFrames) { + continue; + } + + const seconds = localFrame / fps; + const frequencyHz = event.frequencyHz ?? 14; + const angle = seconds * Math.PI * 2 * frequencyHz; + + offsets[event.character] = { + x: Math.sin(angle) * (event.amplitudeX ?? 8), + y: Math.cos(angle * 1.31) * (event.amplitudeY ?? 5), + }; + } + + return offsets; +}; + const resolveMouth: VQMouthResolver = ({ speechId, speakingLocalFrame, @@ -242,11 +282,46 @@ : undefined; const speakingCharacter = activeSpeech?.character; const speakingLocalFrame = activeSpeech ? frame - activeSegment.from : 0; + const activeStill = scheduledEvents.reduce( + (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 timelineEndFrame = scheduledEvents.reduce( (endFrame, scheduledEvent) => Math.max(endFrame, scheduledEvent.from + scheduledEvent.durationInFrames), 0 ); + const avatarShakeOffsetByCharacter = standeeShakeOffsetsForFrame( + scheduledEvents, + frame, + fps + ); + const avatarTranslateXByCharacter: Partial> = {}; + const avatarTranslateYByCharacter: Partial> = { + ...activeSegment.avatarTranslateYByCharacter, + }; + + for (const [character, offset] of Object.entries( + avatarShakeOffsetByCharacter + ) as [CharacterId, Readonly<{x: number; y: number}>][]) { + avatarTranslateXByCharacter[character] = offset.x; + avatarTranslateYByCharacter[character] = + (avatarTranslateYByCharacter[character] ?? 0) + offset.y; + } const sequences = scheduledEvents.map((scheduledEvent, index) => ( @@ -286,6 +361,7 @@ }} > + <VQCharacterStage characters={characters} @@ -302,7 +378,8 @@ fontFamily={roundedFontFamily} stageStyle={{paddingBottom: 130}} avatarFlipXByCharacter={activeSegment.avatarFlipXByCharacter} - avatarTranslateYByCharacter={activeSegment.avatarTranslateYByCharacter} + avatarTranslateXByCharacter={avatarTranslateXByCharacter} + avatarTranslateYByCharacter={avatarTranslateYByCharacter} /> {sequences} </AbsoluteFill>