basyx commited on
Commit
856d25b
·
verified ·
1 Parent(s): 049486f

Validate final template output artifacts

Browse files
Files changed (1) hide show
  1. app/templates/executor.py +504 -0
app/templates/executor.py CHANGED
@@ -0,0 +1,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from types import MappingProxyType
9
+ from typing import Literal
10
+
11
+ from app.core.exceptions import (
12
+ MediaAPIError,
13
+ TemplateExecutionError,
14
+ TemplateValidationError,
15
+ )
16
+ from app.core.logger import get_logger
17
+ from app.core.response import SuccessResponse
18
+ from app.models.media import InputMedia, MediaSource, OperationResult, ResolvedRequest
19
+ from app.operations.compress import compress_video, normalize_audio, normalize_video
20
+ from app.operations.concat import (
21
+ concat_audio,
22
+ concat_video,
23
+ image_sequence,
24
+ image_slideshow,
25
+ image_to_video,
26
+ )
27
+ from app.operations.convert import convert_audio, convert_image, convert_video
28
+ from app.operations.crop import crop_image, crop_video
29
+ from app.operations.extract_audio import (
30
+ extract_audio,
31
+ fade_audio,
32
+ mute_video,
33
+ noise_reduction,
34
+ remove_audio,
35
+ remove_silence,
36
+ replace_audio,
37
+ set_volume,
38
+ )
39
+ from app.operations.merge import merge_audio, merge_video
40
+ from app.operations.mix_audio import duck_background_music
41
+ from app.operations.resize import pad_video, resize_image, resize_video, scale_video
42
+ from app.operations.rotate import (
43
+ change_bitrate,
44
+ change_fps,
45
+ change_speed,
46
+ reverse_video,
47
+ rotate_video,
48
+ slow_motion,
49
+ )
50
+ from app.operations.subtitles import burn_subtitles, soft_subtitles
51
+ from app.operations.thumbnails import (
52
+ blur_video,
53
+ denoise_video,
54
+ extract_frames,
55
+ generate_gif,
56
+ sharpen_video,
57
+ thumbnail,
58
+ )
59
+ from app.operations.trim import trim_audio, trim_video
60
+ from app.operations.watermark import (
61
+ overlay_image,
62
+ overlay_video,
63
+ watermark_image,
64
+ watermark_video,
65
+ )
66
+ from app.services.media_service import MediaProcessor, Operation
67
+ from app.templates.models import PreparedTemplate, ResolvedPipelineStep
68
+ from app.templates.registry import TemplateRegistry
69
+
70
+ logger = get_logger(__name__)
71
+
72
+ OperationKind = Literal["ffmpeg", "whisper", "passthrough"]
73
+ InputMode = Literal["current", "all"]
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class OperationBinding:
78
+ """Binding from a safe YAML operation name to shared application behavior."""
79
+
80
+ kind: OperationKind
81
+ handler: Operation | None = None
82
+ input_mode: InputMode = "current"
83
+ whisper_task: Literal["transcribe", "translate"] = "transcribe"
84
+ aliases: tuple[tuple[str, str], ...] = ()
85
+
86
+
87
+ def _ffmpeg(
88
+ handler: Operation,
89
+ *,
90
+ input_mode: InputMode = "current",
91
+ aliases: tuple[tuple[str, str], ...] = (),
92
+ ) -> OperationBinding:
93
+ return OperationBinding(kind="ffmpeg", handler=handler, input_mode=input_mode, aliases=aliases)
94
+
95
+
96
+ OPERATION_BINDINGS: Mapping[str, OperationBinding] = MappingProxyType(
97
+ {
98
+ "compress": _ffmpeg(compress_video),
99
+ "compress_video": _ffmpeg(compress_video),
100
+ "resize": _ffmpeg(resize_video),
101
+ "resize_video": _ffmpeg(resize_video),
102
+ "crop": _ffmpeg(crop_video),
103
+ "crop_video": _ffmpeg(crop_video),
104
+ "trim": _ffmpeg(trim_video),
105
+ "trim_video": _ffmpeg(trim_video),
106
+ "rotate": _ffmpeg(rotate_video),
107
+ "reverse": _ffmpeg(reverse_video),
108
+ "merge": _ffmpeg(merge_video, input_mode="all"),
109
+ "merge_videos": _ffmpeg(merge_video, input_mode="all"),
110
+ "concat": _ffmpeg(concat_video, input_mode="all"),
111
+ "concat_videos": _ffmpeg(concat_video, input_mode="all"),
112
+ "convert": _ffmpeg(convert_video),
113
+ "convert_video": _ffmpeg(convert_video),
114
+ "overlay": _ffmpeg(overlay_video, input_mode="all"),
115
+ "overlay_video": _ffmpeg(overlay_video, input_mode="all"),
116
+ "watermark": _ffmpeg(watermark_video, input_mode="all"),
117
+ "watermark_video": _ffmpeg(watermark_video, input_mode="all"),
118
+ "extract_frames": _ffmpeg(extract_frames),
119
+ "generate_gif": _ffmpeg(generate_gif),
120
+ "thumbnail": _ffmpeg(thumbnail),
121
+ "replace_audio": _ffmpeg(replace_audio, input_mode="all"),
122
+ "remove_audio": _ffmpeg(remove_audio),
123
+ "mute": _ffmpeg(mute_video),
124
+ "speed": _ffmpeg(change_speed),
125
+ "slow_motion": _ffmpeg(slow_motion),
126
+ "fps": _ffmpeg(change_fps, aliases=(("value", "fps"),)),
127
+ "bitrate": _ffmpeg(change_bitrate, aliases=(("value", "bitrate"),)),
128
+ "burn_subtitles": _ffmpeg(burn_subtitles, input_mode="all"),
129
+ "soft_subtitles": _ffmpeg(soft_subtitles, input_mode="all"),
130
+ "scale": _ffmpeg(scale_video),
131
+ "pad": _ffmpeg(pad_video),
132
+ "blur": _ffmpeg(blur_video),
133
+ "sharpen": _ffmpeg(sharpen_video),
134
+ "denoise": _ffmpeg(denoise_video),
135
+ "normalize_video": _ffmpeg(normalize_video),
136
+ "extract_audio": _ffmpeg(extract_audio),
137
+ "convert_audio": _ffmpeg(convert_audio),
138
+ "normalize_audio": _ffmpeg(normalize_audio),
139
+ "trim_audio": _ffmpeg(trim_audio),
140
+ "merge_audio": _ffmpeg(merge_audio, input_mode="all"),
141
+ "duck_audio": _ffmpeg(duck_background_music, input_mode="all"),
142
+ "concat_audio": _ffmpeg(concat_audio, input_mode="all"),
143
+ "fade_audio": _ffmpeg(fade_audio),
144
+ "volume": _ffmpeg(set_volume),
145
+ "remove_silence": _ffmpeg(remove_silence),
146
+ "noise_reduction": _ffmpeg(noise_reduction),
147
+ "resize_image": _ffmpeg(resize_image),
148
+ "crop_image": _ffmpeg(crop_image),
149
+ "convert_image": _ffmpeg(convert_image),
150
+ "slideshow": _ffmpeg(image_slideshow, input_mode="all"),
151
+ "image_sequence": _ffmpeg(image_sequence, input_mode="all"),
152
+ "image_to_video": _ffmpeg(image_to_video),
153
+ "watermark_image": _ffmpeg(watermark_image, input_mode="all"),
154
+ "overlay_image": _ffmpeg(overlay_image, input_mode="all"),
155
+ "transcribe": OperationBinding(kind="whisper", whisper_task="transcribe"),
156
+ "translate": OperationBinding(kind="whisper", whisper_task="translate"),
157
+ "download": OperationBinding(kind="passthrough"),
158
+ }
159
+ )
160
+
161
+
162
+ class OperationExecutor:
163
+ """Executes allow-listed YAML operations through existing implementations."""
164
+
165
+ def __init__(self, processor: MediaProcessor) -> None:
166
+ self.processor = processor
167
+
168
+ @property
169
+ def supported_operations(self) -> set[str]:
170
+ """Return operation names that are safe for template YAML."""
171
+ return set(OPERATION_BINDINGS)
172
+
173
+ def input_mode(self, operation: str) -> InputMode:
174
+ """Return the default input selection mode for an operation."""
175
+ return self._binding(operation).input_mode
176
+
177
+ async def execute(
178
+ self,
179
+ operation: str,
180
+ inputs: list[InputMedia],
181
+ parameters: dict[str, object],
182
+ output_dir: Path,
183
+ ) -> OperationResult:
184
+ """Execute one pipeline step without publishing its intermediate output."""
185
+ binding = self._binding(operation)
186
+ normalized_parameters = dict(parameters)
187
+ for source, destination in binding.aliases:
188
+ if source in normalized_parameters and destination not in normalized_parameters:
189
+ normalized_parameters[destination] = normalized_parameters[source]
190
+ if binding.kind == "passthrough":
191
+ if not inputs:
192
+ raise TemplateExecutionError("Download step requires an input")
193
+ media = inputs[0]
194
+ return OperationResult(
195
+ path=media.temp_path,
196
+ filename=media.filename,
197
+ mime_type=media.mime_type,
198
+ metadata={"operation": "download", **media.metadata},
199
+ )
200
+ if binding.kind == "whisper":
201
+ normalized_parameters["task"] = binding.whisper_task
202
+ return await self.processor.transcribe_result(inputs, normalized_parameters, output_dir)
203
+ if binding.handler is None: # pragma: no cover - guarded by static bindings
204
+ raise TemplateExecutionError("Template operation has no implementation")
205
+ return await binding.handler(
206
+ self.processor.ffmpeg, inputs, normalized_parameters, output_dir
207
+ )
208
+
209
+ @staticmethod
210
+ def _binding(operation: str) -> OperationBinding:
211
+ binding = OPERATION_BINDINGS.get(operation)
212
+ if binding is None:
213
+ raise TemplateValidationError(f"Unsupported template operation '{operation}'")
214
+ return binding
215
+
216
+
217
+ class TemplateExecutor:
218
+ """Runs validated template pipelines over normalized InputMedia instances."""
219
+
220
+ def __init__(
221
+ self,
222
+ registry: TemplateRegistry,
223
+ operation_executor: OperationExecutor,
224
+ processor: MediaProcessor,
225
+ ) -> None:
226
+ self.registry = registry
227
+ self.operation_executor = operation_executor
228
+ self.processor = processor
229
+
230
+ async def execute_request(self, resolved: ResolvedRequest) -> SuccessResponse:
231
+ """Execute template controls parsed by the shared InputResolver."""
232
+ reference = resolved.params.get("template")
233
+ if not isinstance(reference, str) or not reference.strip():
234
+ raise TemplateValidationError("A non-empty 'template' reference is required")
235
+ parameters = resolved.params.get("parameters", {})
236
+ if not isinstance(parameters, dict):
237
+ raise TemplateValidationError("Template 'parameters' must be an object")
238
+ return await self.execute(resolved, reference, parameters)
239
+
240
+ async def execute(
241
+ self,
242
+ resolved: ResolvedRequest,
243
+ template_reference: str,
244
+ parameters: dict[str, object] | None = None,
245
+ ) -> SuccessResponse:
246
+ """Execute a versioned template and publish only its final artifact."""
247
+ started = time.monotonic()
248
+ cpu_started = time.process_time()
249
+ prepared: PreparedTemplate | None = None
250
+ operations: list[str] = []
251
+ try:
252
+ prepared = self.registry.prepare(template_reference, parameters)
253
+ workspace = await self.processor.cleanup.create_workspace(resolved.request_id)
254
+ initial_metadata = await self.processor.probe_inputs(resolved.inputs)
255
+ originals = list(resolved.inputs)
256
+ current = originals[0]
257
+ artifacts: dict[str, InputMedia] = {}
258
+ for index, step in enumerate(prepared.pipeline):
259
+ if not step.enabled:
260
+ continue
261
+ selected = self._select_inputs(step, current, originals, artifacts)
262
+ step_dir = workspace.outputs / f"{index + 1:03d}_{step.operation}"
263
+ result = await self.operation_executor.execute(
264
+ step.operation, selected, step.parameters, step_dir
265
+ )
266
+ current = self._result_media(result)
267
+ if self._probeable(current):
268
+ await self.processor.probe_inputs([current])
269
+ if step.save_as:
270
+ artifacts[step.save_as] = current
271
+ operations.append(step.operation)
272
+ if not operations:
273
+ raise TemplateExecutionError("All template pipeline operations were disabled")
274
+ self._validate_output(prepared, current)
275
+ output_validation = self._output_validation(current)
276
+ result = OperationResult(
277
+ path=current.temp_path,
278
+ filename=prepared.output.filename or current.filename,
279
+ mime_type=current.mime_type,
280
+ metadata={**current.metadata, "output_validation": output_validation},
281
+ )
282
+ response = await self.processor.finish_result(
283
+ resolved,
284
+ f"template.{prepared.definition.id}",
285
+ result,
286
+ started,
287
+ initial_metadata,
288
+ extra_metadata={
289
+ "template": {
290
+ "id": prepared.definition.id,
291
+ "version": prepared.definition.version,
292
+ "reference": (f"{prepared.definition.id}@{prepared.definition.version}"),
293
+ "category": prepared.definition.category,
294
+ },
295
+ "parameters": prepared.parameters,
296
+ "operations": operations,
297
+ },
298
+ )
299
+ logger.info(
300
+ "template execution completed",
301
+ extra=self._log_data(
302
+ prepared,
303
+ resolved,
304
+ operations,
305
+ started,
306
+ cpu_started,
307
+ response.metadata.get("output_size", 0),
308
+ template_reference,
309
+ parameters,
310
+ ),
311
+ )
312
+ return response
313
+ except MediaAPIError as exc:
314
+ logger.warning(
315
+ "template execution failed",
316
+ extra={
317
+ **self._log_data(
318
+ prepared,
319
+ resolved,
320
+ operations,
321
+ started,
322
+ cpu_started,
323
+ 0,
324
+ template_reference,
325
+ parameters,
326
+ ),
327
+ "error_code": exc.code,
328
+ "error": exc.message,
329
+ },
330
+ )
331
+ raise
332
+ except Exception:
333
+ logger.exception(
334
+ "unexpected template execution error",
335
+ extra=self._log_data(
336
+ prepared,
337
+ resolved,
338
+ operations,
339
+ started,
340
+ cpu_started,
341
+ 0,
342
+ template_reference,
343
+ parameters,
344
+ ),
345
+ )
346
+ raise
347
+ finally:
348
+ try:
349
+ await self.processor.cleanup.complete(resolved.request_id)
350
+ except Exception:
351
+ logger.exception(
352
+ "template workspace completion failed",
353
+ extra={"request_id": resolved.request_id},
354
+ )
355
+
356
+ def _select_inputs(
357
+ self,
358
+ step: ResolvedPipelineStep,
359
+ current: InputMedia,
360
+ originals: list[InputMedia],
361
+ artifacts: dict[str, InputMedia],
362
+ ) -> list[InputMedia]:
363
+ if step.inputs is None:
364
+ if self.operation_executor.input_mode(step.operation) == "all":
365
+ return [current, *originals[1:]]
366
+ return [current]
367
+ selected: list[InputMedia] = []
368
+ for selector in step.inputs:
369
+ if selector == "current":
370
+ selected.append(current)
371
+ elif selector == "original":
372
+ selected.append(originals[0])
373
+ elif selector == "originals":
374
+ selected.extend(originals)
375
+ elif selector.startswith("original:"):
376
+ index = int(selector.split(":", 1)[1])
377
+ try:
378
+ selected.append(originals[index])
379
+ except IndexError as exc:
380
+ raise TemplateExecutionError(
381
+ f"Template requires original input index {index}"
382
+ ) from exc
383
+ else:
384
+ name = selector.split(":", 1)[1]
385
+ try:
386
+ selected.append(artifacts[name])
387
+ except KeyError as exc: # pragma: no cover - definition validation guards order
388
+ raise TemplateExecutionError(
389
+ f"Template artifact '{name}' is unavailable"
390
+ ) from exc
391
+ if not selected:
392
+ raise TemplateExecutionError("Template operation selected no inputs")
393
+ return selected
394
+
395
+ def _result_media(self, result: OperationResult) -> InputMedia:
396
+ if result.path is None or not result.path.is_file():
397
+ raise TemplateExecutionError("A template operation did not produce a file artifact")
398
+ filename = result.filename or result.path.name
399
+ mime_type = result.mime_type or self.processor.validator.infer_mime(result.path)
400
+ return InputMedia(
401
+ source=MediaSource.LOCAL_PATH,
402
+ filename=filename,
403
+ mime_type=mime_type,
404
+ temp_path=result.path,
405
+ size=result.path.stat().st_size,
406
+ metadata=result.metadata,
407
+ )
408
+
409
+ @staticmethod
410
+ def _probeable(media: InputMedia) -> bool:
411
+ return media.mime_type.startswith(("video/", "audio/", "image/"))
412
+
413
+ @staticmethod
414
+ def _validate_output(prepared: PreparedTemplate, media: InputMedia) -> None:
415
+ if not media.temp_path.is_file():
416
+ raise TemplateExecutionError("Template output artifact is missing")
417
+ try:
418
+ size = media.temp_path.stat().st_size
419
+ except OSError as exc:
420
+ raise TemplateExecutionError("Unable to inspect template output artifact") from exc
421
+ if size <= 0:
422
+ raise TemplateExecutionError("Template output artifact is empty")
423
+ if prepared.output.filename:
424
+ filename = prepared.output.filename
425
+ if Path(filename).name != filename or len(filename) > 255:
426
+ raise TemplateValidationError("Template output filename must be a safe basename")
427
+ expected = prepared.output.format.lower().lstrip(".")
428
+ if expected == "source":
429
+ return
430
+ actual = media.temp_path.suffix.lower().lstrip(".")
431
+ aliases = {"jpeg": "jpg", "m4a": "m4a"}
432
+ if aliases.get(actual, actual) != aliases.get(expected, expected):
433
+ raise TemplateExecutionError(
434
+ "Template output did not match its declared format",
435
+ details={"expected": expected, "actual": actual},
436
+ )
437
+
438
+ @staticmethod
439
+ def _output_validation(media: InputMedia) -> dict[str, object]:
440
+ """Summarize checks performed on the final artifact for automation clients."""
441
+ metadata = media.metadata
442
+ size = media.temp_path.stat().st_size
443
+ video_streams = metadata.get("video_streams")
444
+ audio_streams = metadata.get("audio_streams")
445
+ if media.mime_type.startswith("video/") and video_streams is not None and not video_streams:
446
+ raise TemplateExecutionError(
447
+ "Template output is declared as video but contains no video stream"
448
+ )
449
+ if media.mime_type.startswith("audio/") and audio_streams is not None and not audio_streams:
450
+ raise TemplateExecutionError(
451
+ "Template output is declared as audio but contains no audio stream"
452
+ )
453
+ duration = metadata.get("duration")
454
+ if media.mime_type.startswith(("video/", "audio/")) and duration is not None:
455
+ try:
456
+ valid_duration = float(duration) > 0
457
+ except (TypeError, ValueError):
458
+ valid_duration = False
459
+ if not valid_duration:
460
+ raise TemplateExecutionError("Template output has an invalid media duration")
461
+ return {
462
+ "valid": True,
463
+ "size_bytes": size,
464
+ "mime_type": media.mime_type,
465
+ "container": metadata.get("container"),
466
+ "duration_seconds": duration,
467
+ "video_stream_count": len(video_streams) if isinstance(video_streams, list) else None,
468
+ "audio_stream_count": len(audio_streams) if isinstance(audio_streams, list) else None,
469
+ "resolution": metadata.get("resolution"),
470
+ }
471
+
472
+ @staticmethod
473
+ def _log_data(
474
+ prepared: PreparedTemplate | None,
475
+ resolved: ResolvedRequest,
476
+ operations: list[str],
477
+ started: float,
478
+ cpu_started: float,
479
+ output_size: object,
480
+ template_reference: str,
481
+ supplied_parameters: dict[str, object] | None,
482
+ ) -> dict[str, object]:
483
+ memory: int | None = None
484
+ cpu_percent: float | None = None
485
+ try:
486
+ import psutil
487
+
488
+ memory = psutil.Process(os.getpid()).memory_info().rss
489
+ cpu_percent = psutil.cpu_percent(interval=None)
490
+ except ImportError:
491
+ pass
492
+ return {
493
+ "template_id": prepared.definition.id if prepared else template_reference,
494
+ "template_version": prepared.definition.version if prepared else None,
495
+ "template_reference": template_reference,
496
+ "parameters": prepared.parameters if prepared else supplied_parameters or {},
497
+ "request_id": resolved.request_id,
498
+ "operations": operations,
499
+ "duration": round(time.monotonic() - started, 4),
500
+ "cpu_time": round(time.process_time() - cpu_started, 6),
501
+ "cpu_percent": cpu_percent,
502
+ "memory_bytes": memory,
503
+ "output_size": output_size,
504
+ }