Coverage for src / lexigram / contracts / multimedia / types.py: 100%

199 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-19 05:41 +0800

1"""Value objects for the multimedia generation contracts.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass, field 

6from enum import Enum 

7from typing import Any, Literal 

8 

9 

10@dataclass(frozen=True) 

11class MediaAsset: 

12 """Result of a generation call — carries either raw bytes or a URI, never both. 

13 

14 Several v1 providers (ElevenLabs, OpenAI TTS, Stability) return raw bytes 

15 in the response body with no hosted URL, so callers must check 

16 :attr:`has_bytes`/:attr:`has_uri` rather than assuming a shape. 

17 """ 

18 

19 mime_type: str 

20 provider: str 

21 bytes_data: bytes | None = None 

22 uri: str | None = None 

23 metadata: dict[str, Any] = field(default_factory=dict) 

24 

25 @property 

26 def has_bytes(self) -> bool: 

27 return self.bytes_data is not None 

28 

29 @property 

30 def has_uri(self) -> bool: 

31 return self.uri is not None 

32 

33 

34@dataclass(frozen=True) 

35class TTSRequest: 

36 text: str 

37 voice: str | None = None 

38 format: str = "mp3" 

39 reference_audio_uri: str | None = None 

40 emotion: str | None = None 

41 extra: dict[str, Any] = field(default_factory=dict) 

42 

43 

44@dataclass(frozen=True) 

45class MusicRequest: 

46 prompt: str 

47 duration_seconds: float = 30.0 

48 format: str = "mp3" 

49 extra: dict[str, Any] = field(default_factory=dict) 

50 

51 

52class VideoMode(str, Enum): 

53 """Reference-input mode for a video generation request. 

54 

55 ``None`` on the request means the provider derives the mode from which 

56 fields are set (see ``OpenAIVideoProvider._derive_mode``). 

57 """ 

58 

59 TEXT_TO_VIDEO = "text_to_video" 

60 FIRST_FRAME = "first_frame" 

61 FIRST_LAST_FRAME = "first_last_frame" 

62 MULTIMODAL_REFERENCE = "multimodal_reference" 

63 

64 

65@dataclass(frozen=True) 

66class VideoRequest: 

67 prompt: str 

68 duration_seconds: float = 4.0 

69 resolution: str = "1280x720" 

70 image_uri: str | None = None 

71 format: str = "mp4" 

72 model: str | None = None 

73 mode: VideoMode | None = None 

74 last_frame_image: str | None = None 

75 reference_images: list[str] = field(default_factory=list) 

76 reference_videos: list[str] = field(default_factory=list) 

77 reference_audios: list[str] = field(default_factory=list) 

78 generate_audio: bool = False 

79 return_last_frame: bool = False 

80 ratio: str | None = None 

81 seed: int | None = None 

82 extra: dict[str, Any] = field(default_factory=dict) 

83 

84 

85@dataclass(frozen=True) 

86class ImageRequest: 

87 prompt: str 

88 width: int = 1024 

89 height: int = 1024 

90 format: str = "png" 

91 reference_image: bytes | None = None 

92 reference_mime_type: str | None = None 

93 extra: dict[str, Any] = field(default_factory=dict) 

94 

95 

96@dataclass(frozen=True) 

97class InterpolationRequest: 

98 frame_a: MediaAsset 

99 frame_b: MediaAsset 

100 extra: dict[str, Any] = field(default_factory=dict) 

101 

102 

103@dataclass(frozen=True) 

104class UpscaleRequest: 

105 asset: MediaAsset 

106 scale_factor: Literal[2, 4] = 4 

107 extra: dict[str, Any] = field(default_factory=dict) 

108 

109 

110OverlayPosition = Literal[ 

111 "top", 

112 "bottom", 

113 "center", 

114 "top-left", 

115 "top-right", 

116 "bottom-left", 

117 "bottom-right", 

118] 

119 

120 

121@dataclass(frozen=True) 

122class TransitionSpec: 

123 kind: Literal["cut", "crossfade"] 

124 duration: float = 0.5 

125 

126 

127@dataclass(frozen=True) 

128class SubtitleCue: 

129 start: float 

130 end: float 

131 text: str 

132 

133 

134@dataclass(frozen=True) 

135class Trim: 

136 asset: MediaAsset 

137 start: float 

138 end: float 

139 

140 

141@dataclass(frozen=True) 

142class Concat: 

143 assets: list[MediaAsset] 

144 transitions: list[TransitionSpec] | None = None 

145 

146 

147@dataclass(frozen=True) 

148class OverlayText: 

149 asset: MediaAsset 

150 text: str 

151 position: OverlayPosition 

152 start: float | None = None 

153 end: float | None = None 

154 font_size: int = 32 

155 color: str = "white" 

156 

157 

158@dataclass(frozen=True) 

159class OverlayImage: 

160 asset: MediaAsset 

161 image_asset: MediaAsset 

162 position: OverlayPosition 

163 opacity: float = 1.0 

164 start: float | None = None 

165 end: float | None = None 

166 

167 

168@dataclass(frozen=True) 

169class ComposeLayer: 

170 """A full-canvas transparent overlay placed on the base at a time offset. 

171 

172 `start`/`end` are seconds on the composed timeline. `fade_in`/`fade_out` 

173 are durations in seconds on the layer's own timeline (fade-in starts when 

174 the layer appears; fade-out ends at the layer's end). 

175 """ 

176 

177 asset: MediaAsset 

178 start: float = 0.0 

179 end: float | None = None 

180 fade_in: float = 0.0 

181 fade_out: float = 0.0 

182 

183 

184@dataclass(frozen=True) 

185class ComposeAudioLayer: 

186 """An audio input placed at a time offset on the composed timeline.""" 

187 

188 asset: MediaAsset 

189 start: float = 0.0 

190 volume: float = 1.0 

191 

192 

193@dataclass(frozen=True) 

194class EncodeSpec: 

195 """Output encoding parameters for the finished composition.""" 

196 

197 codec: str = "libx264" 

198 bitrate: str | None = None 

199 resolution: str | None = None 

200 fps: int | None = None 

201 

202 

203@dataclass(frozen=True) 

204class ComposeVideo: 

205 """Compose a base video with timed overlays, fades, and audio layers. 

206 

207 Semantics: 

208 - Output duration == base asset duration. 

209 - The base's audio is dropped unless `audio_layers` is non-empty. 

210 - `fade_in`/`fade_out` fade the whole composition from/to black. 

211 - `base_fade_out` fades the BASE stream to black before its end (e.g. a CTA 

212 lead-in) while later overlay layers keep compositing on top. 

213 - `layers` composite over the base in list order (later layers on top) and 

214 are only visible within their `[start, end)` window. 

215 """ 

216 

217 asset: MediaAsset 

218 layers: list[ComposeLayer] = field(default_factory=list) 

219 audio_layers: list[ComposeAudioLayer] = field(default_factory=list) 

220 fade_in: float = 0.0 

221 fade_out: float = 0.0 

222 base_fade_out: float = 0.0 

223 encode: EncodeSpec | None = None 

224 

225 

226@dataclass(frozen=True) 

227class BeatAnalysisRequest: 

228 asset: MediaAsset 

229 extra: dict[str, Any] = field(default_factory=dict) 

230 

231 

232@dataclass(frozen=True) 

233class BeatAnalysisResult: 

234 tempo_bpm: float 

235 beat_timestamps: list[float] 

236 

237 

238@dataclass(frozen=True) 

239class BurnSubtitles: 

240 asset: MediaAsset 

241 cues: list[SubtitleCue] 

242 

243 

244@dataclass(frozen=True) 

245class MuxAudio: 

246 asset: MediaAsset 

247 audio_asset: MediaAsset 

248 mode: Literal["replace", "mix"] 

249 music_volume: float = 1.0 

250 duck_under_existing: bool = False 

251 

252 

253@dataclass(frozen=True) 

254class ExtractThumbnail: 

255 asset: MediaAsset 

256 timestamp: float 

257 

258 

259@dataclass(frozen=True) 

260class ToGif: 

261 asset: MediaAsset 

262 start: float | None = None 

263 end: float | None = None 

264 fps: int = 10 

265 width: int = 480 

266 

267 

268@dataclass(frozen=True) 

269class Transcode: 

270 asset: MediaAsset 

271 format: str 

272 codec: str | None = None 

273 resolution: str | None = None 

274 bitrate: str | None = None 

275 

276 

277@dataclass(frozen=True) 

278class ChangeSpeed: 

279 asset: MediaAsset 

280 factor: float 

281 

282 

283@dataclass(frozen=True) 

284class Crop: 

285 asset: MediaAsset 

286 x: int 

287 y: int 

288 width: int 

289 height: int 

290 

291 

292@dataclass(frozen=True) 

293class ColorFilter: 

294 asset: MediaAsset 

295 preset: Literal["none", "grayscale", "sepia", "vintage"] | None = None 

296 brightness: float = 0.0 

297 contrast: float = 1.0 

298 saturation: float = 1.0 

299 

300 

301@dataclass(frozen=True) 

302class RawFilter: 

303 assets: list[MediaAsset] 

304 filter_complex: str 

305 maps: list[str] 

306 extra_args: list[str] = field(default_factory=list) 

307 

308 

309VideoOperation = ( 

310 Trim 

311 | Concat 

312 | OverlayText 

313 | OverlayImage 

314 | ComposeVideo 

315 | BurnSubtitles 

316 | MuxAudio 

317 | ExtractThumbnail 

318 | ToGif 

319 | Transcode 

320 | ChangeSpeed 

321 | Crop 

322 | ColorFilter 

323 | RawFilter 

324) 

325 

326 

327__all__ = [ 

328 "BeatAnalysisRequest", 

329 "BeatAnalysisResult", 

330 "BurnSubtitles", 

331 "ChangeSpeed", 

332 "ColorFilter", 

333 "ComposeAudioLayer", 

334 "ComposeLayer", 

335 "ComposeVideo", 

336 "Concat", 

337 "Crop", 

338 "EncodeSpec", 

339 "ExtractThumbnail", 

340 "ImageRequest", 

341 "InterpolationRequest", 

342 "MediaAsset", 

343 "MusicRequest", 

344 "MuxAudio", 

345 "OverlayImage", 

346 "OverlayPosition", 

347 "OverlayText", 

348 "RawFilter", 

349 "SubtitleCue", 

350 "TTSRequest", 

351 "ToGif", 

352 "Transcode", 

353 "TransitionSpec", 

354 "Trim", 

355 "UpscaleRequest", 

356 "VideoMode", 

357 "VideoOperation", 

358 "VideoRequest", 

359]