Newer
Older
remotion_docker_devcontainer / voicevox-remotion-template / src / lib / VQRemotionLib / timeline.ts
import {stripVQSubtitleLineBreaks} from "./subtitleText";

export type VQSpeechOptions<Voice = unknown> = Readonly<{
  subtitle?: string;
  readAs?: string;
  voicevox?: Partial<Voice>;
  durationSeconds?: number;
}>;

export type VQSpeechEvent<
  CharacterId extends string = string,
  Voice = unknown
> = Readonly<{
  type: "say";
  id: string;
  character: CharacterId;
  text: string;
  subtitle?: string;
  readAs?: string;
  voicevox?: Partial<Voice>;
  durationSeconds?: number;
}>;

export type VQStillObjectFit =
  | "cover"
  | "contain"
  | "fill"
  | "none"
  | "scale-down";

export type VQStillEvent = Readonly<{
  type: "still";
  id: string;
  imagePath: string;
  durationSeconds?: number;
  fit?: VQStillObjectFit;
  objectPosition?: string;
  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 VQStandeeFacingDirection = "left" | "right";

export type VQStandeeFacingDirectionEvent<
  CharacterId extends string = string
> = Readonly<{
  type: "standeeFacingDirection";
  id: string;
  character: CharacterId;
  direction: VQStandeeFacingDirection;
}>;

export type VQStandeeVerticalOffsetEvent<
  CharacterId extends string = string
> = Readonly<{
  type: "standeeVerticalOffset";
  id: string;
  character: CharacterId;
  translateY: number;
}>;

export type VQWaitEvent = Readonly<{
  type: "wait";
  id: string;
  durationSeconds: number;
}>;

export type VQWaitEventInput = Readonly<{
  type: "wait";
  durationSeconds: number;
}>;

export type VQVideoPlacement = "blocking" | "overlay" | "background";

export type VQVideoPlayback = "once" | "loop";

export type VQAudioPlayback = "once" | "loop";

export type VQAudioEvent = Readonly<{
  type: "audio";
  id: string;
  src: string;
  playback?: VQAudioPlayback;
  durationFrames?: number;
  durationSeconds?: number;
  volume?: number;
  playbackRate?: number;
  toneFrequency?: number;
  loopVolumeCurveBehavior?: "repeat" | "extend";
}>;

export type VQVideoEvent = Readonly<{
  type: "video";
  id: string;
  src: string;
  placement?: VQVideoPlacement;
  playback?: VQVideoPlayback;
  durationFrames?: number;
  durationSeconds?: number;
  trimBeforeFrames?: number;
  trimAfterFrames?: number;
  muted?: boolean;
  volume?: number;
  playbackRate?: number;
  toneFrequency?: number;
  loopVolumeCurveBehavior?: "repeat" | "extend";
  fit?: VQStillObjectFit;
  objectPosition?: string;
  opacity?: number;
  zIndex?: number;
}>;

export type VQCustomTimelineEvent<
  Type extends string,
  Payload extends object = object
> = Readonly<
  {
    type: Type;
  } & Payload
>;

export type VQTimelineEvent<
  CharacterId extends string = string,
  Voice = unknown,
  ExtraEvent extends Readonly<{type: string}> = never
> =
  | VQSpeechEvent<CharacterId, Voice>
  | VQStillEvent
  | VQClearStillEvent
  | VQClearVideoEvent
  | VQStandeePositionEvent<CharacterId>
  | VQStandeeFacingDirectionEvent<CharacterId>
  | VQStandeeVerticalOffsetEvent<CharacterId>
  | VQWaitEvent
  | VQAudioEvent
  | VQVideoEvent
  | ExtraEvent;

export type VQTimelineInputEvent<
  CharacterId extends string = string,
  Voice = unknown,
  ExtraEvent extends Readonly<{type: string}> = never
> =
  | VQSpeechEvent<CharacterId, Voice>
  | VQStillEvent
  | VQClearStillEvent
  | VQClearVideoEvent
  | VQStandeePositionEvent<CharacterId>
  | VQStandeeFacingDirectionEvent<CharacterId>
  | VQStandeeVerticalOffsetEvent<CharacterId>
  | VQWaitEvent
  | VQWaitEventInput
  | VQAudioEvent
  | VQVideoEvent
  | ExtraEvent;

type VQTimelineInputEventBase = Readonly<{type: string}>;

export type VQNormalizeTimelineEvent<Event> =
  Event extends Readonly<{type: "wait"; durationSeconds: infer Duration}>
    ? Readonly<Omit<Event, "id"> & {id: string; durationSeconds: Duration}>
    : Event;

export type VQDefinedTimeline<
  Timeline extends readonly VQTimelineInputEventBase[]
> = {
  readonly [Index in keyof Timeline]: VQNormalizeTimelineEvent<Timeline[Index]>;
};

// 用途: VOICEVOX発話をタイムラインイベントとして定義する。
// 使用方法: script.ts の timeline 内で say("id", "character", "text", options) として呼び出す。
// オプションや引数詳細: options には字幕・読み上げ用テキスト・VOICEVOX上書き・音声未生成時の秒数を指定できる。
export const say = <CharacterId extends string, Voice = unknown>(
  id: string,
  character: CharacterId,
  text: string,
  options: VQSpeechOptions<Voice> = {}
): VQSpeechEvent<CharacterId, Voice> => {
  const strippedText = stripVQSubtitleLineBreaks(text);
  const readAs =
    options.readAs === undefined
      ? strippedText === text
        ? undefined
        : strippedText
      : stripVQSubtitleLineBreaks(options.readAs);

  return {
    type: "say",
    id,
    character,
    text,
    ...options,
    ...(readAs === undefined ? {} : {readAs}),
  };
};

// 用途: 静止画をタイムラインイベントとして定義する。
// 使用方法: script.ts の timeline 内で still("id", "path", options) として呼び出す。
// オプションや引数詳細: options には表示秒数・object-fit・表示位置・透明度を指定できる。
export const still = (
  id: string,
  imagePath: string,
  options: Omit<VQStillEvent, "type" | "id" | "imagePath"> = {}
): VQStillEvent => ({
  type: "still",
  id,
  imagePath,
  ...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<CharacterId, Position> => ({
  type: "standeePosition",
  id,
  character,
  position,
});

// 用途: キャラクター立ち絵の左右の向きを切り替えるタイムラインイベントとして定義する。
// 使用方法: script.ts の timeline 内で standeeFacingDirection("id", "zundamon", "right") として呼び出す。
// オプションや引数詳細: direction は "left" / "right" を指定し、描画側で imageLayout.flipX へ反映する。
export const standeeFacingDirection = <CharacterId extends string>(
  id: string,
  character: CharacterId,
  direction: VQStandeeFacingDirection
): VQStandeeFacingDirectionEvent<CharacterId> => ({
  type: "standeeFacingDirection",
  id,
  character,
  direction,
});

// 用途: キャラクター立ち絵の縦方向オフセットを切り替えるタイムラインイベントとして定義する。
// 使用方法: script.ts の timeline 内で standeeVerticalOffset("id", "zundamon", -120) として呼び出す。
// オプションや引数詳細: translateY は描画側の imageLayout.translateY を上書きするpx値で、負数ほど上へ移動する。
export const standeeVerticalOffset = <CharacterId extends string>(
  id: string,
  character: CharacterId,
  translateY: number
): VQStandeeVerticalOffsetEvent<CharacterId> => ({
  type: "standeeVerticalOffset",
  id,
  character,
  translateY,
});

// 用途: 何も発話しない待機時間をタイムラインイベントとして定義する。
// 使用方法: script.ts の timeline 内で wait(1) のように秒数だけを指定し、defineVQTimelineでIDを確定する。
// オプションや引数詳細: durationSeconds は待機秒数で、IDはtimeline単位で wait-001 から自動採番される。
export const wait = (durationSeconds: number): VQWaitEventInput => ({
  type: "wait",
  durationSeconds,
});

// 用途: 音声をタイムラインイベントとして定義する。
// 使用方法: script.ts の timeline 内で audio("id", "path.wav", options) として呼び出す。
// オプションや引数詳細: playback は "once" / "loop"、duration は親Sequenceの再生尺、volume等はAudioへ渡す。
export const audio = (
  id: string,
  src: string,
  options: Omit<VQAudioEvent, "type" | "id" | "src"> = {}
): VQAudioEvent => ({
  type: "audio",
  id,
  src,
  ...options,
});

// 用途: 動画をタイムラインイベントとして定義する。
// 使用方法: script.ts の timeline 内で video("id", "path.mp4", options) として呼び出す。
// オプションや引数詳細: placement はタイムライン進行、playback は単発/ループ、duration は表示尺を指定する。
export const video = (
  id: string,
  src: string,
  options: Omit<VQVideoEvent, "type" | "id" | "src"> = {}
): VQVideoEvent => ({
  type: "video",
  id,
  src,
  ...options,
});

// 用途: 共通タイムラインイベントが時間カーソルを進めるかを判定する。
// 使用方法: scheduleVQChronologicalScenarioのdoesEventAdvanceTimelineに渡して使う。
// オプションや引数詳細: videoのoverlay/backgroundは現在位置で再生を始め、次イベントを同時に開始できる。
export const doesVQTimelineEventAdvanceTimeline = (
  event: Readonly<{type: string; placement?: string}>
) =>
  event.type !== "clearStill" &&
  event.type !== "clearVideo" &&
  event.type !== "standeePosition" &&
  event.type !== "standeeFacingDirection" &&
  event.type !== "standeeVerticalOffset" &&
  event.type !== "audio" &&
  (event.type !== "video" ||
    event.placement === undefined ||
    event.placement === "blocking");

// 用途: waitイベントの連番からタイムライン内IDを作る。
// 使用方法: defineVQTimeline内で未解決waitイベントを正規化するときに呼び出す。
// オプションや引数詳細: index は1始まりのwait連番で、3桁ゼロ埋めにする。
const waitEventId = (index: number) =>
  `wait-${String(index).padStart(3, "0")}`;

// 用途: タイムライン配列を確定し、未解決のwaitイベントへ安定したIDを付与する。
// 使用方法: script.ts で defineVQTimeline([say(...), wait(1)]) の形でtimelineを定義する。
// オプションや引数詳細: wait IDはこの関数に渡された配列の中だけで先頭から採番される。
export const defineVQTimeline = <
  const Timeline extends readonly VQTimelineInputEventBase[]
>(
  timeline: Timeline
): VQDefinedTimeline<Timeline> => {
  let waitEventIndex = 0;

  return timeline.map((event) => {
    if (event.type === "wait") {
      waitEventIndex += 1;

      if (!("id" in event)) {
        return {
          ...event,
          id: waitEventId(waitEventIndex),
        };
      }
    }

    return event;
  }) as VQDefinedTimeline<Timeline>;
};

// 用途: タイムラインイベントから発話イベントだけを型安全に判定する。
// 使用方法: event.type を直接比較したくない場所で isVQSpeechEvent(event) として使う。
// オプションや引数詳細: CharacterId・Voice・拡張イベント型は呼び出し元のTimelineEventに合わせて推論される。
export const isVQSpeechEvent = <
  CharacterId extends string = string,
  Voice = unknown,
  ExtraEvent extends Readonly<{type: string}> = never
>(
  event: VQTimelineEvent<CharacterId, Voice, ExtraEvent>
): event is VQSpeechEvent<CharacterId, Voice> => event.type === "say";