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:
2026-07-19 10:38:29 +08:00
parent 762bd34f74
commit 23740af816
13 changed files with 962 additions and 13 deletions
@@ -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>
@@ -201,7 +201,14 @@
<!-- Submissions Tab -->
<TabsContent value="submissions" class="flex-1 m-0 overflow-hidden">
<TaskSubmissions :task-id="taskId" :submissions="submissions" @submissions-updated="loadSubmissions" />
<TaskSubmissions
:task-id="taskId"
:submissions="submissions"
:task-type="task?.task_type"
:project-id="task?.project_id"
:name="task?.shot_name || task?.asset_name"
@submissions-updated="loadSubmissions"
/>
</TabsContent>
</Tabs>
</div>
@@ -22,6 +22,25 @@
<!-- Submit Work Form (Bottom) -->
<div class="flex-shrink-0 border-t bg-background p-2">
<div class="space-y-2">
<div v-if="submissionConfig" class="text-xs text-muted-foreground space-y-0.5 px-1">
<div class="flex items-center gap-1.5">
<span v-if="submissionConfig.allowed_extensions.length > 0">
Allowed: {{ submissionConfig.allowed_extensions.join(', ') }}
</span>
<Badge v-if="submissionConfig.required" variant="outline" class="text-[10px] px-1 py-0">Required</Badge>
</div>
<div v-if="submissionConfig.naming_pattern && submissionConfig.check_naming">
Naming: <code>{{ submissionConfig.naming_pattern }}</code>
</div>
<div v-if="submissionConfig.check_movie_spec && (submissionConfig.movie_resolution || submissionConfig.movie_format || submissionConfig.movie_codec || submissionConfig.movie_frame_rate)">
Movie spec:
<span v-if="submissionConfig.movie_resolution">{{ submissionConfig.movie_resolution }}</span>
<span v-if="submissionConfig.movie_format">.{{ submissionConfig.movie_format }}</span>
<span v-if="submissionConfig.movie_codec">({{ submissionConfig.movie_codec }})</span>
<span v-if="submissionConfig.movie_frame_rate">{{ submissionConfig.movie_frame_rate }}fps</span>
</div>
</div>
<div class="border-2 border-dashed rounded-lg p-3 text-center hover:border-primary/50 transition-colors">
<input
ref="fileInput"
@@ -110,7 +129,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { Upload } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
@@ -124,12 +143,18 @@ import {
} from '@/components/ui/dialog'
import SubmissionCard from './SubmissionCard.vue'
import { taskService, type Submission } from '@/services/task'
import { projectService, type SubmissionTypeConfig } from '@/services/project'
import { useToast } from '@/components/ui/toast/use-toast'
import { apiClient } from '@/services/api'
import mediaInfoFactory, { isTrackType } from 'mediainfo.js'
import mediaInfoWasmUrl from 'mediainfo.js/MediaInfoModule.wasm?url'
const props = defineProps<{
taskId: number
submissions: Submission[]
taskType?: string
projectId?: number
name?: string
}>()
const emit = defineEmits<{
@@ -144,12 +169,184 @@ const submissionNotes = ref('')
const viewerOpen = ref(false)
const selectedSubmission = ref<Submission | null>(null)
const mediaBlobUrl = ref<string | null>(null)
const submissionConfig = ref<SubmissionTypeConfig | null>(null)
async function loadSubmissionConfig() {
submissionConfig.value = null
if (!props.projectId || !props.taskType) return
try {
const config = await projectService.getProjectSubmissionConfig(props.projectId)
submissionConfig.value = config.config_by_task_type[props.taskType] || null
} catch (error) {
console.error('Failed to load submission configuration:', error)
}
}
onMounted(loadSubmissionConfig)
watch(() => [props.projectId, props.taskType], loadSubmissionConfig)
const MOVIE_EXTENSIONS = ['.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf']
let mediaInfoPromise: ReturnType<typeof mediaInfoFactory> | null = null
function getMediaInfo() {
if (!mediaInfoPromise) {
mediaInfoPromise = mediaInfoFactory({ locateFile: () => mediaInfoWasmUrl })
}
return mediaInfoPromise
}
interface VideoTrackInfo {
width: number | null
height: number | null
format: string | null
codecId: string | null
formatCommercial: string | null
frameRate: number | null
}
// Parses container/codec metadata directly from the file's bytes (no playback) - works for
// containers browsers can't play natively, e.g. MXF, or codecs like ProRes/DNxHD
async function analyzeVideoFile(file: File): Promise<VideoTrackInfo | null> {
try {
const mediainfo = await getMediaInfo()
const getSize = () => file.size
const readChunk = (chunkSize: number, offset: number) =>
new Promise<Uint8Array>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer))
reader.onerror = () => reject(reader.error)
reader.readAsArrayBuffer(file.slice(offset, offset + chunkSize))
})
const result = await mediainfo.analyzeData(getSize, readChunk)
const videoTrack = result.media?.track.find(t => isTrackType(t, 'Video'))
if (!videoTrack) return null
const track = videoTrack as unknown as Record<string, unknown>
const commercial = [track.Format_Commercial, track.Format_Commercial_IfAny]
.filter((v): v is string => typeof v === 'string')
.join(' ')
return {
width: videoTrack.Width ?? null,
height: videoTrack.Height ?? null,
format: videoTrack.Format ?? null,
codecId: videoTrack.CodecID ?? null,
formatCommercial: commercial || null,
frameRate: videoTrack.FrameRate ?? null
}
} catch (error) {
console.error('Failed to analyze video file:', error)
return null
}
}
const CODEC_PATTERNS: Record<string, RegExp> = {
h264: /\b(avc|h\.?264)\b/i,
h265: /\b(hevc|h\.?265)\b/i,
mjpeg: /\bm?jpeg\b/i,
dnxhd: /\bdnxhd\b/i,
dnxhr: /\bdnxhr\b/i,
prores: /\bprores\b/i,
uncompressed: /\b(uncompressed|raw)\b/i,
avid: /\bavid\b/i,
cineform: /\bcineform\b/i
}
function normalizeCodec(info: VideoTrackInfo): string | null {
const haystack = [info.format, info.formatCommercial, info.codecId].filter(Boolean).join(' ')
for (const [key, pattern] of Object.entries(CODEC_PATTERNS)) {
if (pattern.test(haystack)) return key
}
return null
}
// All checking happens client-side, entirely in the browser, before the file is ever uploaded
async function findSubmissionViolation(file: File): Promise<string | null> {
const config = submissionConfig.value
if (!config) return null
const filename = file.name
const dotIndex = filename.lastIndexOf('.')
const stem = dotIndex > 0 ? filename.slice(0, dotIndex) : filename
const extension = dotIndex > 0 ? filename.slice(dotIndex).toLowerCase() : ''
if (config.allowed_extensions.length > 0 && !config.allowed_extensions.includes(extension)) {
return `File type '${extension}' is not accepted for ${props.taskType} submissions. Allowed types: ${config.allowed_extensions.join(', ')}`
}
if (config.check_naming && config.naming_pattern) {
if (config.naming_pattern.includes('{name}') && !props.name) {
return null // can't resolve {name} client-side
}
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = config.naming_pattern
.split(/(\{name\}|\{task_type\}|\{version\})/)
.map(part => {
if (part === '{name}') return escapeRegex(props.name || '')
if (part === '{task_type}') return escapeRegex(props.taskType || '')
if (part === '{version}') return '\\d{3}'
return escapeRegex(part)
})
.join('')
const regex = new RegExp(`^${pattern}$`)
if (!regex.test(stem)) {
const example = config.naming_pattern
.replace('{name}', props.name || 'name')
.replace('{task_type}', props.taskType || '')
.replace('{version}', '001')
return `Filename does not match the required naming convention '${config.naming_pattern}' (e.g. '${example}${extension}')`
}
}
if (config.check_movie_spec && MOVIE_EXTENSIONS.includes(extension)) {
if (config.movie_format && extension.slice(1) !== config.movie_format.toLowerCase()) {
return `Movie format '${extension}' does not match the required format '.${config.movie_format}'`
}
if (config.movie_resolution || config.movie_codec || config.movie_frame_rate) {
const info = await analyzeVideoFile(file)
if (info) {
if (config.movie_resolution && info.width && info.height) {
const actual = `${info.width}x${info.height}`
if (actual !== config.movie_resolution) {
return `Video resolution ${actual} does not match the required resolution ${config.movie_resolution}`
}
}
if (config.movie_codec) {
const actualCodec = normalizeCodec(info)
if (actualCodec && actualCodec !== config.movie_codec) {
return `Video codec '${actualCodec}' does not match the required codec '${config.movie_codec}'`
}
// If the codec couldn't be identified, don't false-block
}
if (config.movie_frame_rate && info.frameRate) {
if (Math.abs(info.frameRate - config.movie_frame_rate) > 0.05) {
return `Video frame rate ${info.frameRate}fps does not match the required frame rate ${config.movie_frame_rate}fps`
}
}
}
// If the file couldn't be analyzed at all, don't false-block
}
}
return null
}
async function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
const violation = await findSubmissionViolation(file)
if (violation) {
toast({
title: 'Invalid submission',
description: violation,
variant: 'destructive'
})
if (fileInput.value) {
fileInput.value.value = ''
}
return
}
uploading.value = true
try {
await taskService.submitWork(
+26
View File
@@ -95,6 +95,22 @@ export interface ProjectSettings {
enabled_shot_tasks?: string[]
}
export interface SubmissionTypeConfig {
allowed_extensions: string[]
naming_pattern?: string | null
check_naming: boolean
required: boolean
check_movie_spec: boolean
movie_resolution?: string | null
movie_format?: string | null
movie_codec?: string | null
movie_frame_rate?: number | null
}
export interface ProjectSubmissionConfig {
config_by_task_type: Record<string, SubmissionTypeConfig>
}
export const projectService = {
async getUserProjects(): Promise<Project[]> {
const response = await apiClient.get('/projects/')
@@ -182,6 +198,16 @@ export const projectService = {
return response.data
},
async getProjectSubmissionConfig(projectId: number): Promise<ProjectSubmissionConfig> {
const response = await apiClient.get(`/projects/${projectId}/submission-config`)
return response.data
},
async updateProjectSubmissionConfig(projectId: number, config: ProjectSubmissionConfig): Promise<ProjectSubmissionConfig> {
const response = await apiClient.put(`/projects/${projectId}/submission-config`, config)
return response.data
},
async uploadThumbnail(projectId: number, file: File): Promise<{ message: string; thumbnail_url: string }> {
const formData = new FormData()
formData.append('file', file)
+16 -4
View File
@@ -33,7 +33,7 @@
<!-- Tabbed Interface -->
<Tabs :default-value="activeTab" @update:model-value="(val) => activeTab = String(val)" class="w-full">
<TabsList class="grid w-full grid-cols-6">
<TabsList class="grid w-full grid-cols-7">
<TabsTrigger value="general">
<Settings class="h-4 w-4 mr-2" />
General
@@ -54,6 +54,10 @@
<ListChecks class="h-4 w-4 mr-2" />
Tasks
</TabsTrigger>
<TabsTrigger value="submissions">
<UploadCloud class="h-4 w-4 mr-2" />
Submissions
</TabsTrigger>
<TabsTrigger value="storage">
<FolderOpen class="h-4 w-4 mr-2" />
Storage
@@ -156,6 +160,13 @@
</div>
</TabsContent>
<!-- Submission Configuration Tab -->
<TabsContent value="submissions" class="mt-6">
<div class="bg-card rounded-lg border p-6">
<SubmissionConfigManager :project-id="projectId" />
</div>
</TabsContent>
<!-- Upload Location Tab -->
<TabsContent value="storage" class="mt-6">
<div class="bg-card rounded-lg border p-6">
@@ -175,9 +186,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
AlertCircle, ArrowLeft, Settings, Cog, Users, Film,
ListChecks, FolderOpen
import {
AlertCircle, ArrowLeft, Settings, Cog, Users, Film,
ListChecks, FolderOpen, UploadCloud
} from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -195,6 +206,7 @@ import CustomTaskStatusManager from "@/components/settings/CustomTaskStatusManag
import CustomTaskTypeManager from "@/components/settings/CustomTaskTypeManager.vue";
import DefaultTaskTemplatesEditor from "@/components/settings/DefaultTaskTemplatesEditor.vue";
import UploadLocationConfig from "@/components/settings/UploadLocationConfig.vue";
import SubmissionConfigManager from "@/components/project/SubmissionConfigManager.vue";
import { projectService } from "@/services/project";
import type { Project } from "@/stores/projects";