Files
LinkDesk/backend/schemas/submission_config.py
T
indigo 23740af816 Add per-task-type submission configuration with client-side movie spec checks
Coordinators can configure, per task type, accepted file extensions, a
naming convention, and (for video files) required resolution/format/
codec/frame rate. All checking happens entirely client-side in the
browser before upload - the backend only stores and validates the
configuration itself, never inspects submitted files.

- backend: submission_config_by_task_type JSON column on Project,
  schemas/submission_config.py for validation, GET/PUT
  /projects/{id}/submission-config endpoints. Added .mxf/.ma/.usd(a/c)
  to the supported file formats.
- frontend: new Submissions tab in Project Settings
  (SubmissionConfigManager.vue) to configure rules per task type.
  TaskSubmissions.vue enforces them before upload: extension/naming
  checks via regex, and real video resolution/codec/frame rate
  detection via mediainfo.js (parses container metadata directly,
  so it works for formats browsers can't play natively like MXF,
  ProRes, or DNxHD).
2026-07-19 10:38:29 +08:00

104 lines
4.5 KiB
Python

"""
Pydantic schemas for per-task-type submission configuration
"""
from pydantic import BaseModel, Field, validator
from typing import Optional, Dict, List
import re
from utils.file_handler import file_handler
# Tokens supported in a naming_pattern: {name} (shot/asset name), {task_type}, {version}
NAMING_PATTERN_TOKEN_RE = re.compile(r'\{(name|task_type|version)\}')
NAMING_PATTERN_ALLOWED_CHARS_RE = re.compile(r'^[A-Za-z0-9_\-\.]*$')
MOVIE_FORMATS = {ext.lstrip('.') for ext in file_handler.MOVIE_EXTENSIONS}
MOVIE_CODECS = {'h264', 'h265', 'mjpeg', 'dnxhd', 'dnxhr', 'prores', 'uncompressed', 'avid', 'cineform'}
MOVIE_FRAME_RATES = {23.976, 24, 30, 48, 60}
RESOLUTION_RE = re.compile(r'^\d+x\d+$')
class SubmissionTypeConfig(BaseModel):
"""Submission rules for a single task type.
All checking against these rules happens client-side (in TaskSubmissions.vue) when an
artist picks a file to submit - this schema only validates and stores the configuration
itself, it is never used to inspect an uploaded file server-side.
"""
allowed_extensions: List[str] = Field(default_factory=list, description="Accepted file extensions, e.g. ['.mov', '.exr']")
naming_pattern: Optional[str] = Field(None, description="Filename pattern using {name}, {task_type}, {version} tokens")
check_naming: bool = Field(True, description="Whether the naming pattern is actively checked")
required: bool = Field(False, description="Whether a submission is expected for this task type (informational only)")
check_movie_spec: bool = Field(False, description="Whether to check video submissions against movie_resolution/movie_format/movie_codec/movie_frame_rate")
movie_resolution: Optional[str] = Field(None, description="Required video resolution, e.g. '1920x1080'")
movie_format: Optional[str] = Field(None, description="Required video format, e.g. 'mov'")
movie_codec: Optional[str] = Field(None, description="Required video codec, e.g. 'h264'")
movie_frame_rate: Optional[float] = Field(None, description="Required video frame rate, e.g. 23.976")
@validator('allowed_extensions')
def validate_allowed_extensions(cls, v):
normalized = []
for ext in v:
ext = ext.lower()
if not ext.startswith('.'):
ext = f'.{ext}'
if ext not in file_handler.SUPPORTED_FORMATS:
raise ValueError(
f"Extension '{ext}' is not a supported file format. "
f"Supported formats: {', '.join(sorted(file_handler.SUPPORTED_FORMATS))}"
)
normalized.append(ext)
return normalized
@validator('naming_pattern')
def validate_naming_pattern(cls, v):
if v is None or v == '':
return None
# Strip out known tokens, then everything left must be safe literal characters
stripped = NAMING_PATTERN_TOKEN_RE.sub('', v)
if not NAMING_PATTERN_ALLOWED_CHARS_RE.match(stripped):
raise ValueError(
'Naming pattern may only contain letters, numbers, "_", "-", "." '
'and the tokens {name}, {task_type}, {version}'
)
return v
@validator('movie_resolution')
def validate_movie_resolution(cls, v):
if v is None or v == '':
return None
if not RESOLUTION_RE.match(v):
raise ValueError('Movie resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
return v
@validator('movie_format')
def validate_movie_format(cls, v):
if v is None or v == '':
return None
v = v.lower().lstrip('.')
if v not in MOVIE_FORMATS:
raise ValueError(f'Movie format must be one of: {", ".join(sorted(MOVIE_FORMATS))}')
return v
@validator('movie_codec')
def validate_movie_codec(cls, v):
if v is None or v == '':
return None
v = v.lower()
if v not in MOVIE_CODECS:
raise ValueError(f'Movie codec must be one of: {", ".join(sorted(MOVIE_CODECS))}')
return v
@validator('movie_frame_rate')
def validate_movie_frame_rate(cls, v):
if v is None:
return None
if not any(abs(v - rate) < 0.001 for rate in MOVIE_FRAME_RATES):
raise ValueError(f'Movie frame rate must be one of: {", ".join(str(r) for r in sorted(MOVIE_FRAME_RATES))}')
return v
class ProjectSubmissionConfig(BaseModel):
"""Submission configuration for a project, keyed by task type"""
config_by_task_type: Dict[str, SubmissionTypeConfig] = Field(default_factory=dict)