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).
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Submission Configuration</h3>
|
||||
<p class="text-sm text-muted-foreground">Configure accepted file types and naming conventions artists must follow when submitting work, per task type</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="text-center py-8 text-sm text-muted-foreground">
|
||||
Loading submission configuration...
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<Card v-for="taskType in taskTypes" :key="taskType">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-base capitalize">{{ formatTaskType(taskType) }}</CardTitle>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label :for="`${taskType}_required`" class="text-xs text-muted-foreground">Required</Label>
|
||||
<Switch
|
||||
:id="`${taskType}_required`"
|
||||
:model-value="getConfig(taskType).required"
|
||||
@update:model-value="(val) => (getConfig(taskType).required = !!val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div>
|
||||
<Label class="text-xs">Allowed File Types</Label>
|
||||
<div class="flex flex-wrap gap-3 mt-1">
|
||||
<label
|
||||
v-for="ext in ALLOWED_EXTENSIONS"
|
||||
:key="ext"
|
||||
class="flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="getConfig(taskType).allowed_extensions.includes(ext)"
|
||||
@update:model-value="(val) => toggleExtension(taskType, ext, !!val)"
|
||||
/>
|
||||
{{ ext }}
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">Leave all unchecked to accept any supported file type</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Label :for="`${taskType}_pattern`" class="text-xs">Naming Pattern</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label :for="`${taskType}_check_naming`" class="text-xs text-muted-foreground">Check</Label>
|
||||
<Switch
|
||||
:id="`${taskType}_check_naming`"
|
||||
:model-value="getConfig(taskType).check_naming"
|
||||
@update:model-value="(val) => (getConfig(taskType).check_naming = !!val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
:id="`${taskType}_pattern`"
|
||||
v-model="getConfig(taskType).naming_pattern"
|
||||
placeholder="e.g. {name}_{task_type}_v{version}"
|
||||
class="h-8"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
Tokens: <code>{name}</code> (shot/asset name), <code>{task_type}</code>, <code>{version}</code> (auto 3-digit)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class="text-xs">Movie Spec</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label :for="`${taskType}_check_movie_spec`" class="text-xs text-muted-foreground">Check</Label>
|
||||
<Switch
|
||||
:id="`${taskType}_check_movie_spec`"
|
||||
:model-value="getConfig(taskType).check_movie_spec"
|
||||
@update:model-value="(val) => (getConfig(taskType).check_movie_spec = !!val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="getConfig(taskType).check_movie_spec" class="grid grid-cols-4 gap-3 mt-1">
|
||||
<div>
|
||||
<Input
|
||||
v-model="getConfig(taskType).movie_resolution"
|
||||
placeholder="e.g. 1920x1080"
|
||||
class="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
:model-value="getConfig(taskType).movie_format || undefined"
|
||||
@update:model-value="(val) => (getConfig(taskType).movie_format = val ? String(val) : '')"
|
||||
>
|
||||
<SelectTrigger class="h-8">
|
||||
<SelectValue placeholder="Format" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="fmt in MOVIE_FORMATS" :key="fmt" :value="fmt">{{ fmt }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
:model-value="getConfig(taskType).movie_codec || undefined"
|
||||
@update:model-value="(val) => (getConfig(taskType).movie_codec = val ? String(val) : '')"
|
||||
>
|
||||
<SelectTrigger class="h-8">
|
||||
<SelectValue placeholder="Codec" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="codec in MOVIE_CODECS" :key="codec" :value="codec">{{ codec }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
:model-value="getConfig(taskType).movie_frame_rate ? String(getConfig(taskType).movie_frame_rate) : undefined"
|
||||
@update:model-value="(val) => (getConfig(taskType).movie_frame_rate = val ? Number(val) : null)"
|
||||
>
|
||||
<SelectTrigger class="h-8">
|
||||
<SelectValue placeholder="Frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="rate in MOVIE_FRAME_RATES" :key="rate" :value="String(rate)">{{ rate }} fps</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">Checks resolution/format/codec/frame rate for video submissions only, in-browser before upload</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div v-if="taskTypes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
|
||||
No task types configured for this project yet.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button :disabled="isLoading || isSaving" @click="onSave">
|
||||
<div v-if="isSaving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Save Submission Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { projectService, type SubmissionTypeConfig } from '@/services/project'
|
||||
import { customTaskTypeService } from '@/services/customTaskType'
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { toast } = useToast()
|
||||
|
||||
// Mirrors backend's file_handler.SUPPORTED_FORMATS - kept for the picker only; the actual check happens client-side at submit time
|
||||
const ALLOWED_EXTENSIONS = [
|
||||
'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf',
|
||||
'.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr',
|
||||
'.pdf', '.txt', '.doc', '.docx',
|
||||
'.zip', '.rar', '.7z',
|
||||
'.ma', '.usd', '.usda', '.usdc'
|
||||
]
|
||||
|
||||
const MOVIE_FORMATS = ['mov', 'mp4', 'avi', 'mkv', 'webm', 'mxf']
|
||||
const MOVIE_CODECS = ['h264', 'h265', 'mjpeg', 'dnxhd', 'dnxhr', 'prores', 'uncompressed', 'avid', 'cineform']
|
||||
const MOVIE_FRAME_RATES = [23.976, 24, 30, 48, 60]
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const taskTypes = ref<string[]>([])
|
||||
const formState = reactive<Record<string, SubmissionTypeConfig>>({})
|
||||
|
||||
const formatTaskType = (taskType: string) => taskType.replace(/_/g, ' ')
|
||||
|
||||
const getConfig = (taskType: string): SubmissionTypeConfig => {
|
||||
if (!formState[taskType]) {
|
||||
formState[taskType] = {
|
||||
allowed_extensions: [],
|
||||
naming_pattern: '',
|
||||
check_naming: true,
|
||||
required: false,
|
||||
check_movie_spec: false,
|
||||
movie_resolution: '',
|
||||
movie_format: '',
|
||||
movie_codec: '',
|
||||
movie_frame_rate: null
|
||||
}
|
||||
}
|
||||
return formState[taskType]
|
||||
}
|
||||
|
||||
const toggleExtension = (taskType: string, ext: string, checked: boolean) => {
|
||||
const config = getConfig(taskType)
|
||||
if (checked) {
|
||||
if (!config.allowed_extensions.includes(ext)) config.allowed_extensions.push(ext)
|
||||
} else {
|
||||
config.allowed_extensions = config.allowed_extensions.filter(e => e !== ext)
|
||||
}
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const [allTypes, config] = await Promise.all([
|
||||
customTaskTypeService.getAllTaskTypes(props.projectId),
|
||||
projectService.getProjectSubmissionConfig(props.projectId)
|
||||
])
|
||||
|
||||
taskTypes.value = Array.from(new Set([...allTypes.asset_task_types, ...allTypes.shot_task_types]))
|
||||
|
||||
for (const [taskType, cfg] of Object.entries(config.config_by_task_type)) {
|
||||
formState[taskType] = {
|
||||
allowed_extensions: [...cfg.allowed_extensions],
|
||||
naming_pattern: cfg.naming_pattern || '',
|
||||
check_naming: cfg.check_naming,
|
||||
required: cfg.required,
|
||||
check_movie_spec: cfg.check_movie_spec,
|
||||
movie_resolution: cfg.movie_resolution || '',
|
||||
movie_format: cfg.movie_format || '',
|
||||
movie_codec: cfg.movie_codec || '',
|
||||
movie_frame_rate: cfg.movie_frame_rate ?? null
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load submission configuration:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load submission configuration',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
try {
|
||||
isSaving.value = true
|
||||
const configByTaskType: Record<string, SubmissionTypeConfig> = {}
|
||||
for (const taskType of taskTypes.value) {
|
||||
const config = formState[taskType]
|
||||
if (!config) continue
|
||||
const hasConfig = config.allowed_extensions.length > 0 || !!config.naming_pattern
|
||||
|| config.required || config.check_movie_spec
|
||||
if (hasConfig) {
|
||||
configByTaskType[taskType] = {
|
||||
allowed_extensions: config.allowed_extensions,
|
||||
naming_pattern: config.naming_pattern || null,
|
||||
check_naming: config.check_naming,
|
||||
required: config.required,
|
||||
check_movie_spec: config.check_movie_spec,
|
||||
movie_resolution: config.movie_resolution || null,
|
||||
movie_format: config.movie_format || null,
|
||||
movie_codec: config.movie_codec || null,
|
||||
movie_frame_rate: config.movie_frame_rate || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await projectService.updateProjectSubmissionConfig(props.projectId, { config_by_task_type: configByTaskType })
|
||||
|
||||
toast({
|
||||
title: 'Submission Configuration Updated',
|
||||
description: 'Submission rules have been saved successfully'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save submission configuration:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to save submission configuration',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
Reference in New Issue
Block a user