Просмотр исходного кода

Add videos and descriptions from the technician app, and an All tab on Files & media (#455)

Bernt Christian Egeland 2 недель назад
Родитель
Сommit
1be38d5c6f

+ 12 - 1
e2e/specs/work-orders/modern-layout.spec.ts

@@ -73,9 +73,20 @@ test.describe('the work order page', () => {
     await expect(files).toBeVisible()
     await expect(page.getByRole('button', { name: /^Images/ })).toHaveCount(0)
     await expect(page.getByRole('button', { name: /^Status Reports/ })).toHaveCount(0)
-    for (const name of [/^Photos/, /^Documents/, /^Diagnostics/, /^Video/, /^Status Reports/]) {
+    for (const name of [
+      /^All/,
+      /^Photos/,
+      /^Documents/,
+      /^Diagnostics/,
+      /^Video/,
+      /^Status Reports/,
+    ]) {
       await expect(files.getByRole('tab', { name })).toBeVisible()
     }
+    // It opens on everything, and each kind has its own way in.
+    await expect(files.getByRole('tab', { name: /^All/ })).toHaveAttribute('aria-selected', 'true')
+    await expect(files.getByTestId('media-add')).toContainText('Add files')
+    await files.getByRole('tab', { name: /^Photos/ }).click()
     await expect(files.getByTestId('media-add')).toContainText('Add photos')
     await files.getByRole('tab', { name: /^Status Reports/ }).click()
     await expect(files.getByTestId('status-reports-section')).toBeVisible()

+ 2 - 1
e2e/specs/work-orders/modern-page.spec.ts

@@ -388,7 +388,8 @@ test.describe('the overhauled work order page', () => {
       timeout: 15_000,
     })
     await dialog.getByRole('button', { name: 'Done' }).click()
-    await expect(files.getByTestId('media-tile')).toHaveCount(before + 1)
+    // The card is on all of the job's files, so both are there.
+    await expect(files.getByTestId('media-tile')).toHaveCount(before + 2)
     await files.getByRole('tab', { name: /^Documents/ }).click()
     // A document's name is its caption until somebody writes one.
     await expect(files.getByPlaceholder(`e2e-registration-${stamp}.pdf`)).toHaveCount(1)

+ 2 - 0
messages/de/service.json

@@ -617,6 +617,7 @@
     "titleField": "Titel",
     "media": {
       "tabs": {
+        "all": "Alle",
         "photos": "Fotos",
         "diagnostics": "Diagnose",
         "dropoff": "Annahme"
@@ -626,6 +627,7 @@
       "showToCustomer": "Dem Kunden zeigen",
       "hideFromCustomer": "Vor dem Kunden verbergen",
       "add": {
+        "all": "Dateien hinzufügen",
         "image": "Fotos hinzufügen",
         "document": "Dokumente hinzufügen",
         "diagnostic": "Diagnosebericht hinzufügen",

+ 2 - 0
messages/en/service.json

@@ -617,6 +617,7 @@
     "titleField": "Title",
     "media": {
       "tabs": {
+        "all": "All",
         "photos": "Photos",
         "diagnostics": "Diagnostics",
         "dropoff": "Drop-off"
@@ -626,6 +627,7 @@
       "showToCustomer": "Show to the customer",
       "hideFromCustomer": "Hide from the customer",
       "add": {
+        "all": "Add files",
         "image": "Add photos",
         "document": "Add documents",
         "diagnostic": "Add diagnostic report",

+ 2 - 0
messages/es/service.json

@@ -617,6 +617,7 @@
     "titleField": "Título",
     "media": {
       "tabs": {
+        "all": "Todo",
         "photos": "Fotos",
         "diagnostics": "Diagnóstico",
         "dropoff": "Recepción"
@@ -626,6 +627,7 @@
       "showToCustomer": "Mostrar al cliente",
       "hideFromCustomer": "Ocultar al cliente",
       "add": {
+        "all": "Añadir archivos",
         "image": "Añadir fotos",
         "document": "Añadir documentos",
         "diagnostic": "Añadir informe de diagnóstico",

+ 2 - 0
messages/fr/service.json

@@ -617,6 +617,7 @@
     "titleField": "Titre",
     "media": {
       "tabs": {
+        "all": "Tout",
         "photos": "Photos",
         "diagnostics": "Diagnostics",
         "dropoff": "Réception"
@@ -626,6 +627,7 @@
       "showToCustomer": "Montrer au client",
       "hideFromCustomer": "Masquer au client",
       "add": {
+        "all": "Ajouter des fichiers",
         "image": "Ajouter des photos",
         "document": "Ajouter des documents",
         "diagnostic": "Ajouter un rapport de diagnostic",

+ 2 - 0
messages/it/service.json

@@ -617,6 +617,7 @@
     "titleField": "Titolo",
     "media": {
       "tabs": {
+        "all": "Tutti",
         "photos": "Foto",
         "diagnostics": "Diagnosi",
         "dropoff": "Accettazione"
@@ -626,6 +627,7 @@
       "showToCustomer": "Mostra al cliente",
       "hideFromCustomer": "Nascondi al cliente",
       "add": {
+        "all": "Aggiungi file",
         "image": "Aggiungi foto",
         "document": "Aggiungi documenti",
         "diagnostic": "Aggiungi rapporto diagnostico",

+ 2 - 0
messages/lt/service.json

@@ -617,6 +617,7 @@
     "titleField": "Pavadinimas",
     "media": {
       "tabs": {
+        "all": "Visi",
         "photos": "Nuotraukos",
         "diagnostics": "Diagnostika",
         "dropoff": "Priėmimas"
@@ -626,6 +627,7 @@
       "showToCustomer": "Rodyti klientui",
       "hideFromCustomer": "Slėpti nuo kliento",
       "add": {
+        "all": "Pridėti failus",
         "image": "Pridėti nuotraukų",
         "document": "Pridėti dokumentų",
         "diagnostic": "Pridėti diagnostikos ataskaitą",

+ 2 - 0
messages/nb/service.json

@@ -617,6 +617,7 @@
     "titleField": "Tittel",
     "media": {
       "tabs": {
+        "all": "Alle",
         "photos": "Bilder",
         "diagnostics": "Diagnostikk",
         "dropoff": "Innlevering"
@@ -626,6 +627,7 @@
       "showToCustomer": "Vis for kunden",
       "hideFromCustomer": "Skjul for kunden",
       "add": {
+        "all": "Legg til filer",
         "image": "Legg til bilder",
         "document": "Legg til dokumenter",
         "diagnostic": "Legg til diagnoserapport",

+ 2 - 0
messages/nl/service.json

@@ -617,6 +617,7 @@
     "titleField": "Titel",
     "media": {
       "tabs": {
+        "all": "Alles",
         "photos": "Foto's",
         "diagnostics": "Diagnose",
         "dropoff": "Inname"
@@ -626,6 +627,7 @@
       "showToCustomer": "Aan de klant tonen",
       "hideFromCustomer": "Voor de klant verbergen",
       "add": {
+        "all": "Bestanden toevoegen",
         "image": "Foto's toevoegen",
         "document": "Documenten toevoegen",
         "diagnostic": "Diagnostisch verslag toevoegen",

+ 2 - 0
messages/pl/service.json

@@ -617,6 +617,7 @@
     "titleField": "Tytuł",
     "media": {
       "tabs": {
+        "all": "Wszystko",
         "photos": "Zdjęcia",
         "diagnostics": "Diagnostyka",
         "dropoff": "Przyjęcie"
@@ -626,6 +627,7 @@
       "showToCustomer": "Pokaż klientowi",
       "hideFromCustomer": "Ukryj przed klientem",
       "add": {
+        "all": "Dodaj pliki",
         "image": "Dodaj zdjęcia",
         "document": "Dodaj dokumenty",
         "diagnostic": "Dodaj raport diagnostyczny",

+ 2 - 0
messages/pt-BR/service.json

@@ -617,6 +617,7 @@
     "titleField": "Título",
     "media": {
       "tabs": {
+        "all": "Tudo",
         "photos": "Fotos",
         "diagnostics": "Diagnóstico",
         "dropoff": "Entrada"
@@ -626,6 +627,7 @@
       "showToCustomer": "Mostrar ao cliente",
       "hideFromCustomer": "Ocultar do cliente",
       "add": {
+        "all": "Adicionar arquivos",
         "image": "Adicionar fotos",
         "document": "Adicionar documentos",
         "diagnostic": "Adicionar relatório de diagnóstico",

+ 2 - 0
messages/pt-PT/service.json

@@ -617,6 +617,7 @@
     "titleField": "Título",
     "media": {
       "tabs": {
+        "all": "Tudo",
         "photos": "Fotografias",
         "diagnostics": "Diagnóstico",
         "dropoff": "Receção"
@@ -626,6 +627,7 @@
       "showToCustomer": "Mostrar ao cliente",
       "hideFromCustomer": "Ocultar do cliente",
       "add": {
+        "all": "Adicionar ficheiros",
         "image": "Adicionar fotografias",
         "document": "Adicionar documentos",
         "diagnostic": "Adicionar relatório de diagnóstico",

+ 2 - 0
messages/ru/service.json

@@ -617,6 +617,7 @@
     "titleField": "Название",
     "media": {
       "tabs": {
+        "all": "Все",
         "photos": "Фото",
         "diagnostics": "Диагностика",
         "dropoff": "Приёмка"
@@ -626,6 +627,7 @@
       "showToCustomer": "Показать клиенту",
       "hideFromCustomer": "Скрыть от клиента",
       "add": {
+        "all": "Добавить файлы",
         "image": "Добавить фото",
         "document": "Добавить документы",
         "diagnostic": "Добавить диагностический отчёт",

+ 2 - 0
messages/tr/service.json

@@ -617,6 +617,7 @@
     "titleField": "Başlık",
     "media": {
       "tabs": {
+        "all": "Tümü",
         "photos": "Fotoğraflar",
         "diagnostics": "Tanı",
         "dropoff": "Teslim alma"
@@ -626,6 +627,7 @@
       "showToCustomer": "Müşteriye göster",
       "hideFromCustomer": "Müşteriden gizle",
       "add": {
+        "all": "Dosya ekle",
         "image": "Fotoğraf ekle",
         "document": "Belge ekle",
         "diagnostic": "Tanı raporu ekle",

+ 39 - 0
src/__tests__/lib/byte-range.test.ts

@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest'
+import { parseByteRange } from '@/lib/byte-range'
+
+describe('parseByteRange', () => {
+  it('has nothing to honour without a header', () => {
+    expect(parseByteRange(null, 1000)).toBeNull()
+  })
+
+  it('reads the two-byte probe a video player opens with', () => {
+    expect(parseByteRange('bytes=0-1', 1000)).toEqual({ start: 0, end: 1 })
+  })
+
+  it('reads an open-ended range to the end of the file', () => {
+    expect(parseByteRange('bytes=200-', 1000)).toEqual({ start: 200, end: 999 })
+  })
+
+  it('reads a suffix as the last bytes of the file', () => {
+    expect(parseByteRange('bytes=-100', 1000)).toEqual({ start: 900, end: 999 })
+    expect(parseByteRange('bytes=-5000', 1000)).toEqual({ start: 0, end: 999 })
+  })
+
+  it('clamps an end past the file to its last byte', () => {
+    expect(parseByteRange('bytes=900-5000', 1000)).toEqual({ start: 900, end: 999 })
+  })
+
+  it('refuses a start at or past the end of the file', () => {
+    expect(parseByteRange('bytes=1000-', 1000)).toBe('unsatisfiable')
+    expect(parseByteRange('bytes=-0', 1000)).toBe('unsatisfiable')
+    expect(parseByteRange('bytes=0-', 0)).toBe('unsatisfiable')
+  })
+
+  it('falls back to the whole file for anything it does not understand', () => {
+    expect(parseByteRange('bytes=0-1,5-9', 1000)).toBeNull()
+    expect(parseByteRange('items=0-1', 1000)).toBeNull()
+    expect(parseByteRange('bytes=-', 1000)).toBeNull()
+    expect(parseByteRange('bytes=9-2', 1000)).toBeNull()
+    expect(parseByteRange('bytes=abc', 1000)).toBeNull()
+  })
+})

+ 70 - 0
src/__tests__/lib/video-downscale.test.ts

@@ -0,0 +1,70 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({ db: {} }))
+vi.mock('@/lib/files/manager', () => ({
+  discardUnsavedUpload: vi.fn(),
+  releaseFiles: vi.fn(),
+}))
+
+import { downscaleArgs, needsDownscale, parseProbedSize } from '@/lib/video-downscale'
+
+describe('needsDownscale', () => {
+  it('leaves 1080p alone, whichever way the phone was held', () => {
+    expect(needsDownscale(1920, 1080)).toBe(false)
+    expect(needsDownscale(1080, 1920)).toBe(false)
+    expect(needsDownscale(1280, 720)).toBe(false)
+  })
+
+  it('catches 4K and 1440p in both orientations', () => {
+    expect(needsDownscale(3840, 2160)).toBe(true)
+    expect(needsDownscale(2160, 3840)).toBe(true)
+    expect(needsDownscale(2560, 1440)).toBe(true)
+  })
+})
+
+describe('parseProbedSize', () => {
+  it('reads the size ffprobe prints', () => {
+    expect(parseProbedSize('3840x2160\n')).toEqual({ width: 3840, height: 2160 })
+  })
+
+  it('reads the first line when a stream prints side data after it', () => {
+    expect(parseProbedSize('1920x1080\n\n')).toEqual({ width: 1920, height: 1080 })
+  })
+
+  it('has no size for anything else', () => {
+    expect(parseProbedSize('')).toBeNull()
+    expect(parseProbedSize('N/A')).toBeNull()
+    expect(parseProbedSize('0x0')).toBeNull()
+  })
+})
+
+describe('downscaleArgs', () => {
+  const args = downscaleArgs('/uploads/in.mov', 'mp4', '/uploads/out.mp4')
+
+  it('pins the demuxer and opens local files only', () => {
+    expect(args.slice(0, 7)).toEqual([
+      '-nostdin',
+      '-protocol_whitelist',
+      'file,crypto',
+      '-f',
+      'mov',
+      '-i',
+      '/uploads/in.mov',
+    ])
+  })
+
+  it('scales the shorter side to 1080 so an upright clip stays upright', () => {
+    const filter = args[args.indexOf('-vf') + 1]
+    expect(filter).toBe("scale='if(gt(iw,ih),-2,1080)':'if(gt(iw,ih),1080,-2)'")
+  })
+
+  it('keeps picture and sound and nothing else', () => {
+    expect(args).toContain('-sn')
+    expect(args).toContain('-dn')
+    expect(args.filter((arg) => arg === '-map')).toHaveLength(2)
+  })
+
+  it('writes the output last', () => {
+    expect(args.at(-1)).toBe('/uploads/out.mp4')
+  })
+})

+ 39 - 6
src/app/api/protected/files/[...path]/route.ts

@@ -1,9 +1,10 @@
 import { NextRequest, NextResponse } from 'next/server'
 import { svgDownloadHeaders } from '@/lib/upload-url'
 import { getAuthContext } from '@/lib/get-auth-context'
-import { readFile, stat } from 'fs/promises'
+import { open, readFile, stat } from 'fs/promises'
 import path from 'path'
 import { uploadsRoot } from '@/lib/upload-root'
+import { parseByteRange } from '@/lib/byte-range'
 
 const MIME_TYPES: Record<string, string> = {
   jpg: 'image/jpeg',
@@ -20,7 +21,7 @@ const MIME_TYPES: Record<string, string> = {
 }
 
 export async function GET(
-  _request: NextRequest,
+  request: NextRequest,
   { params }: { params: Promise<{ path: string[] }> }
 ) {
   const ctx = await getAuthContext()
@@ -66,24 +67,56 @@ export async function GET(
 
   const filePath = path.join(uploadsRoot(), orgId, category, filename)
 
+  let size: number
   try {
-    await stat(filePath)
+    size = (await stat(filePath)).size
   } catch {
     return NextResponse.json({ error: 'Not found' }, { status: 404 })
   }
 
-  const buffer = await readFile(filePath)
   const ext = filename.split('.').pop()?.toLowerCase() || ''
   if (ext === 'svg') {
-    return new NextResponse(buffer, {
+    return new NextResponse(await readFile(filePath), {
       headers: svgDownloadHeaders('private, max-age=31536000, immutable'),
     })
   }
   const contentType = MIME_TYPES[ext] || 'application/octet-stream'
 
-  return new NextResponse(buffer, {
+  // A video player asks for the piece it needs rather than the whole clip,
+  // and an iPhone will not play one from a server that cannot answer that.
+  const range = parseByteRange(request.headers.get('range'), size)
+  if (range === 'unsatisfiable') {
+    return new NextResponse(null, {
+      status: 416,
+      headers: { 'Content-Range': `bytes */${size}` },
+    })
+  }
+  if (range) {
+    // Only the span asked for is read, so seeking through a 60 MB clip does
+    // not pull all of it into memory on every request.
+    const chunk = Buffer.alloc(range.end - range.start + 1)
+    const handle = await open(filePath, 'r')
+    try {
+      await handle.read(chunk, 0, chunk.length, range.start)
+    } finally {
+      await handle.close()
+    }
+    return new NextResponse(chunk, {
+      status: 206,
+      headers: {
+        'Content-Type': contentType,
+        'Content-Range': `bytes ${range.start}-${range.end}/${size}`,
+        'Content-Length': String(chunk.length),
+        'Accept-Ranges': 'bytes',
+        'Cache-Control': 'private, max-age=31536000, immutable',
+      },
+    })
+  }
+
+  return new NextResponse(await readFile(filePath), {
     headers: {
       'Content-Type': contentType,
+      'Accept-Ranges': 'bytes',
       'Cache-Control': 'private, max-age=31536000, immutable',
     },
   })

+ 2 - 2
src/app/api/v1/tech/jobs/[id]/attachments/[attachmentId]/route.ts

@@ -4,7 +4,7 @@ import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
 
 /**
- * Removes a photo from a job.
+ * Removes a photo or video from a job.
  *
  * A technician photographing a dark wheel arch takes three before one is
  * usable, and without this the other two stay on the job forever and end up
@@ -37,7 +37,7 @@ export async function DELETE(
         },
         select: { id: true, fileUrl: true },
       })
-      if (!attachment) return apiError(404, 'not_found', 'That photo is not on this job.')
+      if (!attachment) return apiError(404, 'not_found', 'That file is not on this job.')
 
       await db.serviceAttachment.delete({ where: { id: attachment.id } })
 

+ 43 - 9
src/app/api/v1/tech/jobs/[id]/attachments/route.ts

@@ -1,13 +1,15 @@
 import { mkdir, stat, writeFile } from 'node:fs/promises'
 import crypto from 'node:crypto'
 import path from 'node:path'
+import { after } from 'next/server'
 import { db } from '@/lib/db'
 import { getFeatures, type PlanFeatures } from '@/lib/features'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { apiError, apiOk, withApiAuth } from '@/lib/with-api-auth'
 import { uploadsRoot } from '@/lib/upload-root'
 import { assertContentLength } from '@/lib/backup/zip-guard'
-import { isDeclaredVideo, sniffDeclaredVideo } from '@/lib/video-sniff'
+import { queueVideoDownscale } from '@/lib/video-downscale'
+import { isDeclaredVideo, sniffDeclaredVideo, type VideoContainer } from '@/lib/video-sniff'
 
 /**
  * Attaches a photo or video shot in the bay to a job.
@@ -39,11 +41,19 @@ const ALLOWED = new Map<string, string>([
  * is a request that will not finish, and a limit that cannot be met in the
  * field is not a limit, it is a timeout with extra steps.
  */
-const MAX_BYTES = 60 * 1024 * 1024
+const MAX_PHOTO_BYTES = 60 * 1024 * 1024
+
+/**
+ * More room for a video, because not every one comes from the app's own
+ * recorder. That one writes 1080p and stays well under the photo limit; a
+ * clip picked from the library was shot at whatever the phone was set to,
+ * and a short 4K take is already past it. Those are scaled down once saved.
+ */
+const MAX_VIDEO_BYTES = 250 * 1024 * 1024
 
 // The multipart framing around the file adds a little to the body. The
 // margin keeps a file just under the cap from being refused for it.
-const MAX_BODY = MAX_BYTES + 1024 * 1024
+const MAX_BODY = MAX_VIDEO_BYTES + 1024 * 1024
 
 const CATEGORY_LIMIT: Record<string, keyof PlanFeatures | undefined> = {
   image: 'maxImagesPerService',
@@ -71,7 +81,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
       try {
         assertContentLength(request, MAX_BODY)
       } catch {
-        return apiError(413, 'invalid_request', 'That file is too large. Keep it under 60 MB.')
+        return apiError(413, 'invalid_request', 'That file is too large. Keep it under 250 MB.')
       }
 
       const form = await request.formData()
@@ -87,19 +97,28 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
       if (file.size === 0) {
         return apiError(400, 'invalid_request', 'That file is empty.')
       }
-      if (file.size > MAX_BYTES) {
-        return apiError(400, 'invalid_request', 'That file is too large. Keep it under 60 MB.')
+      const maxBytes = isDeclaredVideo(file.type) ? MAX_VIDEO_BYTES : MAX_PHOTO_BYTES
+      if (file.size > maxBytes) {
+        return apiError(
+          400,
+          'invalid_request',
+          `That file is too large. Keep it under ${maxBytes / (1024 * 1024)} MB.`
+        )
       }
 
       const bytes = new Uint8Array(await file.arrayBuffer())
 
       // The declared type is the client's word. A video is kept, served and
       // played as one only when its bytes are the container it claims.
-      if (isDeclaredVideo(file.type) && !sniffDeclaredVideo(file.type, bytes)) {
-        return apiError(400, 'invalid_request', 'That file is not a video we can read.')
+      let container: VideoContainer | null = null
+      if (isDeclaredVideo(file.type)) {
+        container = sniffDeclaredVideo(file.type, bytes)
+        if (!container) {
+          return apiError(400, 'invalid_request', 'That file is not a video we can read.')
+        }
       }
 
-      const category = isDeclaredVideo(file.type) ? 'video' : 'image'
+      const category = container ? 'video' : 'image'
       const description = (form.get('description') as string | null)?.slice(0, 500) || undefined
 
       const limitKey = CATEGORY_LIMIT[category]
@@ -148,10 +167,25 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
           fileType: true,
           fileSize: true,
           category: true,
+          description: true,
           createdAt: true,
         },
       })
 
+      // After the answer, not before it: the phone has its yes, and a video
+      // shot above 1080p is brought down to it without anyone waiting.
+      if (container) {
+        const videoContainer = container
+        after(() =>
+          queueVideoDownscale({
+            attachmentId: attachment.id,
+            organizationId: ctx.organizationId,
+            fileUrl: attachment.fileUrl,
+            container: videoContainer,
+          })
+        )
+      }
+
       return apiOk({ attachment }, 201)
     },
     {

+ 1 - 0
src/app/api/v1/tech/jobs/[id]/route.ts

@@ -84,6 +84,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
               fileUrl: true,
               fileName: true,
               fileType: true,
+              description: true,
               createdAt: true,
             },
             orderBy: { createdAt: 'desc' },

+ 4 - 0
src/app/api/v1/tech/me/route.ts

@@ -1,6 +1,7 @@
 import { db } from '@/lib/db'
 import { apiOk, withApiAuth } from '@/lib/with-api-auth'
 import { getOpenEntry, loggedMinutes } from '@/features/time-tracking/Lib/timeEntries'
+import { TECH_API_FEATURES } from '@/lib/tech-api-features'
 import { MIN_APP_VERSION } from '@/lib/tech-app-version'
 
 /**
@@ -40,6 +41,9 @@ export async function GET(request: Request) {
         // Repeated from /health because that one is only read at setup, and a
         // minimum the app never re-checks is a minimum that cannot be raised.
         minAppVersion: MIN_APP_VERSION,
+        // What a newer app may rely on here. An app that finds a name missing
+        // switches that part off rather than letting it fail.
+        features: TECH_API_FEATURES,
         user,
         organization,
         technicians,

+ 30 - 5
src/features/vehicles/Components/service-page/modern/FilesMediaCard.tsx

@@ -16,7 +16,14 @@ import { Checkbox } from '@/components/ui/checkbox'
 import { toast } from 'sonner'
 import { useServiceType } from '@/components/service-type-context'
 
-type FileTab = 'images' | 'dropoff' | 'documents' | 'diagnostics' | 'video' | 'statusReports'
+type FileTab =
+  | 'all'
+  | 'images'
+  | 'dropoff'
+  | 'documents'
+  | 'diagnostics'
+  | 'video'
+  | 'statusReports'
 
 /** A walk round a car is a handful of shots; this is the phone route's cap too. */
 const MAX_DROPOFF = 30
@@ -49,7 +56,9 @@ interface FilesMediaCardProps {
 
 /**
  * Everything filed on the job in one card: photos, documents, diagnostic
- * reports, video, and the status reports sent to the customer. The classic
+ * reports, video, and the status reports sent to the customer. It opens on
+ * all of the files together, since the question is usually "what is on this
+ * job", and the tabs after it are the same files one kind at a time. The classic
  * page gives most of these a tab of its own at the top of the page; here they
  * are tabs of one card, each a grid of tiles with the way to add one at the end.
  *
@@ -71,7 +80,7 @@ export function FilesMediaCard({
   maxImages,
   maxDiagnostics,
   maxDocuments,
-  initialTab = 'images',
+  initialTab = 'all',
   statusReports,
 }: FilesMediaCardProps) {
   const t = useTranslations('service')
@@ -123,8 +132,16 @@ export function FilesMediaCard({
   const diagnostics = documents.filter((file) => file.category === 'diagnostic')
   const otherDocuments = documents.filter((file) => file.category !== 'diagnostic')
 
+  const total = images.length + dropoff.length + videos.length + documents.length
+  // Everything on the job in the order it arrived, whichever tab it is filed
+  // under. The status reports are not files and keep to their own tab.
+  const everything = [...images, ...dropoff, ...documents, ...videos].sort(
+    (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
+  )
+
   // A tab under a plan cap says how much of it is used, as the classic photo tab did.
   const tabs: { value: FileTab; label: string; count: number; max?: number }[] = [
+    { value: 'all', label: t('modern.media.tabs.all'), count: total },
     {
       value: 'images',
       label: t('modern.media.tabs.photos'),
@@ -159,8 +176,6 @@ export function FilesMediaCard({
         ]
       : []),
   ]
-  const total = images.length + dropoff.length + videos.length + documents.length
-
   return (
     <div
       id="files-media"
@@ -224,6 +239,16 @@ export function FilesMediaCard({
         {/* Each grid is keyed by its tab, so switching tabs starts it from the
             job's files rather than from the last tab's tiles. */}
         <div role="tabpanel" className="@container">
+          {tab === 'all' && (
+            <MediaGrid
+              key="all"
+              kind="all"
+              serviceRecordId={serviceRecordId}
+              files={everything}
+              caps={{ image: capOf(maxImages), document: capOf(maxDocuments) }}
+              customerId={customerId}
+            />
+          )}
           {tab === 'images' && (
             <MediaGrid
               key="image"

+ 79 - 33
src/features/vehicles/Components/service-page/modern/MediaGrid.tsx

@@ -70,6 +70,26 @@ const KINDS: Record<
   },
 }
 
+/** The union of what the single kinds take, for the grid that shows them all. */
+const ANY_ACCEPT = '.jpg,.jpeg,.png,.webp,.mp4,.webm,.mov,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt'
+
+/** The kind a file is drawn as: its own category, or a document for one this grid does not know. */
+function kindOfFile(file: Attachment): MediaKind {
+  return file.category in KINDS ? (file.category as MediaKind) : 'document'
+}
+
+/**
+ * Where a file added to the grid of everything is filed. A photo is a photo
+ * and a video a video; anything else is a document. A diagnostic report and
+ * a drop-off photo are what somebody says they are, not what the file is, so
+ * those are added from their own tabs.
+ */
+function kindForUpload(file: File): MediaKind {
+  if (KINDS.image.types(file.type)) return 'image'
+  if (KINDS.video.types(file.type)) return 'video'
+  return 'document'
+}
+
 type Result<T = unknown> = { success: boolean; data?: T; error?: string }
 
 /**
@@ -104,13 +124,16 @@ function serviceStore(serviceRecordId: string): MediaStore {
 }
 
 interface MediaGridProps {
-  kind: MediaKind
+  /** One kind of file, or 'all' for every file on the job in one grid. */
+  kind: MediaKind | 'all'
   serviceRecordId: string
   /** Saves somewhere other than the work order's attachments. */
   store?: MediaStore
   files: Attachment[]
   /** The plan's cap for this kind of file; absent means no cap. */
   max?: number
+  /** The caps by kind, for the grid of everything, where one number cannot say it. */
+  caps?: Partial<Record<MediaKind, number>>
   /** Photos can be sent to the customer over WhatsApp when there is one. */
   customerId?: string
 }
@@ -131,6 +154,7 @@ export function MediaGrid({
   store: customStore,
   files,
   max,
+  caps,
   customerId,
 }: MediaGridProps) {
   const store = customStore ?? serviceStore(serviceRecordId)
@@ -152,48 +176,66 @@ export function MediaGrid({
     setItems(files)
   }, [savedKey]) // eslint-disable-line react-hooks/exhaustive-deps -- keyed on the ids, not the array's identity
 
-  const config = KINDS[kind]
-  const Icon = config.icon
+  const all = kind === 'all'
+  const kindOf = (file: Attachment): MediaKind => (kind === 'all' ? kindOfFile(file) : kind)
   const atLimit = max !== undefined && items.length >= max
-  const isPhotos = kind === 'image' || kind === 'dropoff'
+  // The carousel pages through the photos only, wherever they sit in the grid.
+  const photos = items.filter((file) => file.fileType.startsWith('image/'))
 
   // A drop-off photo taken from the phone's list carries which shot it is as
   // its description ('front', 'odometer'). That is shown in words, and stays
   // the shot's name until somebody types a caption of their own.
   const captionOf = (file: Attachment) =>
-    kind === 'dropoff' && isDropoffSlot(file.description)
+    kindOf(file) === 'dropoff' && isDropoffSlot(file.description)
       ? t(`photoHandoff.dropoff.slots.${file.description}`)
       : file.description || ''
 
-  const upload = async (list: FileList | File[]) => {
-    let chosen = Array.from(list)
-    const refused = chosen.filter((file) => !config.types(file.type))
+  /** The files of one kind that may go up: the right type, and within the plan's cap. */
+  const admit = (target: MediaKind, candidates: File[], cap: number | undefined): File[] => {
+    let chosen = candidates
+    const refused = chosen.filter((file) => !KINDS[target].types(file.type))
     if (refused.length > 0) {
       toast.error(t('modern.media.wrongType', { names: refused.map((f) => f.name).join(', ') }))
-      chosen = chosen.filter((file) => config.types(file.type))
+      chosen = chosen.filter((file) => KINDS[target].types(file.type))
     }
-    if (max !== undefined) {
-      const room = max - items.length
-      if (room <= 0) {
-        toast.error(t('modern.media.limit', { count: items.length, max }))
-        return
-      }
-      if (chosen.length > room) {
-        toast.warning(
-          t(isPhotos ? 'images.onlyUploading' : 'documents.onlyUploading', {
-            count: room,
-          })
-        )
-        chosen = chosen.slice(0, room)
-      }
+    if (cap === undefined) return chosen
+
+    const held = all ? items.filter((file) => kindOf(file) === target).length : items.length
+    const room = cap - held
+    if (room <= 0) {
+      if (chosen.length > 0) toast.error(t('modern.media.limit', { count: held, max: cap }))
+      return []
     }
+    if (chosen.length > room) {
+      const photo = target === 'image' || target === 'dropoff'
+      toast.warning(t(photo ? 'images.onlyUploading' : 'documents.onlyUploading', { count: room }))
+      chosen = chosen.slice(0, room)
+    }
+    return chosen
+  }
+
+  const upload = async (list: FileList | File[]) => {
+    const picked = Array.from(list)
+    // One kind on its own tab; on the grid of everything each file goes
+    // where its type says.
+    const chosen: { file: File; target: MediaKind }[] =
+      kind === 'all'
+        ? (['image', 'video', 'document'] as const).flatMap((target) =>
+            admit(
+              target,
+              picked.filter((file) => kindForUpload(file) === target),
+              caps?.[target]
+            ).map((file) => ({ file, target }))
+          )
+        : admit(kind, picked, max).map((file) => ({ file, target: kind }))
     if (chosen.length === 0) return
 
     setUploading(true)
     let added = 0
-    for (let file of chosen) {
+    for (const { file: original, target } of chosen) {
+      let file = original
       try {
-        if (isPhotos) file = await compressImage(file)
+        if (target === 'image' || target === 'dropoff') file = await compressImage(file)
         const body = new FormData()
         body.append('file', file)
         const res = await fetch('/api/protected/upload/service-files', { method: 'POST', body })
@@ -208,12 +250,12 @@ export function MediaGrid({
           fileUrl: data.url,
           fileType: data.fileType,
           fileSize: data.fileSize,
-          category: kind,
+          category: target,
           // Shown to the customer unless somebody says otherwise, as the
           // classic tabs do. For a video that means the shared link, which
           // plays it; the printed invoice has nothing to draw for one.
           // A drop-off photo is the workshop's own record, so it starts hidden.
-          includeInInvoice: kind !== 'dropoff',
+          includeInInvoice: target !== 'dropoff',
         })
         if (result.success && result.data) {
           setItems((prev) => [...prev, result.data as Attachment])
@@ -267,7 +309,9 @@ export function MediaGrid({
 
   return (
     <div className="grid grid-cols-2 gap-3 p-5 pt-4 @xl:grid-cols-3 @3xl:grid-cols-4">
-      {items.map((file, index) => {
+      {items.map((file) => {
+        const config = KINDS[kindOf(file)]
+        const Icon = config.icon
         const isImage = file.fileType.startsWith('image/')
         const isVideo = file.fileType.startsWith('video/')
         return (
@@ -280,7 +324,7 @@ export function MediaGrid({
               {isImage ? (
                 <button
                   type="button"
-                  onClick={() => setCarouselIndex(index)}
+                  onClick={() => setCarouselIndex(photos.indexOf(file))}
                   className="h-full w-full cursor-zoom-in"
                   aria-label={file.description || file.fileName}
                 >
@@ -388,7 +432,9 @@ export function MediaGrid({
                 <p className="truncate text-xs text-muted-foreground" suppressHydrationWarning>
                   {/* To the minute for a drop-off photo: when it was taken is
                       the point of it. */}
-                  {kind === 'dropoff' ? formatDateTime(file.createdAt) : formatDate(file.createdAt)}{' '}
+                  {kindOf(file) === 'dropoff'
+                    ? formatDateTime(file.createdAt)
+                    : formatDate(file.createdAt)}{' '}
                   · {formatFileSize(file.fileSize)}
                 </p>
                 {isImage && customerId && !customStore && (
@@ -451,7 +497,7 @@ export function MediaGrid({
         ref={inputRef}
         type="file"
         multiple
-        accept={config.accept}
+        accept={kind === 'all' ? ANY_ACCEPT : KINDS[kind].accept}
         className="hidden"
         onChange={(e) => {
           if (e.target.files?.length) void upload(e.target.files)
@@ -476,9 +522,9 @@ export function MediaGrid({
         </DialogContent>
       </Dialog>
 
-      {isPhotos && (
+      {photos.length > 0 && (
         <ImageCarousel
-          images={items}
+          images={photos}
           currentIndex={carouselIndex}
           onClose={() => setCarouselIndex(null)}
           onChangeIndex={setCarouselIndex}

+ 40 - 0
src/lib/byte-range.ts

@@ -0,0 +1,40 @@
+/**
+ * Reads a `Range` request header against a file of a known size.
+ *
+ * A video player does not download a clip and then play it. It asks for the
+ * first two bytes to learn whether the server can seek, then for the pieces
+ * it needs, and iOS refuses to play at all from a server that answers a
+ * range request with the whole file.
+ *
+ * Returns the inclusive byte span to send, `'unsatisfiable'` when the span
+ * starts past the end of the file, and null when there is no range to honour:
+ * no header, a unit other than bytes, a malformed value, or several ranges at
+ * once, which nothing here asks for and which is answered with the whole file.
+ */
+export function parseByteRange(
+  header: string | null,
+  size: number
+): { start: number; end: number } | 'unsatisfiable' | null {
+  if (!header) return null
+
+  const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
+  if (!match) return null
+
+  const [, from, to] = match
+  if (from === '' && to === '') return null
+
+  // `bytes=-500` is the last 500 bytes, not a span starting at zero.
+  if (from === '') {
+    const length = Number(to)
+    if (length === 0 || size === 0) return 'unsatisfiable'
+    return { start: Math.max(size - length, 0), end: size - 1 }
+  }
+
+  const start = Number(from)
+  if (start >= size) return 'unsatisfiable'
+
+  const end = to === '' ? size - 1 : Math.min(Number(to), size - 1)
+  if (end < start) return null
+
+  return { start, end }
+}

+ 17 - 0
src/lib/tech-api-features.ts

@@ -0,0 +1,17 @@
+/**
+ * What this server can do for the technician app, by name.
+ *
+ * The app is installed from a store and the server by whoever runs the
+ * workshop, so the two are rarely the same age. A newer app talking to an
+ * older self-hosted server has no way to know which of its screens will work,
+ * and finds out by failing in front of a technician. With this list it asks
+ * instead, and what the server does not name it switches off with a note.
+ *
+ * Add a name when the app has to behave differently without it. Never remove
+ * or rename one: an installed app looks for the exact string.
+ *
+ * - `job-video`: videos on a job play in the app (the file route answers byte
+ *   ranges), a video may be up to 250 MB and is scaled down to 1080p, and the
+ *   job carries each attachment's description.
+ */
+export const TECH_API_FEATURES = ['job-video'] as const

+ 209 - 0
src/lib/video-downscale.ts

@@ -0,0 +1,209 @@
+import { execFile } from 'node:child_process'
+import crypto from 'node:crypto'
+import { stat } from 'node:fs/promises'
+import path from 'node:path'
+import { promisify } from 'node:util'
+import { db } from '@/lib/db'
+import { discardUnsavedUpload, releaseFiles } from '@/lib/files/manager'
+import { uploadsRoot } from '@/lib/upload-root'
+import { ffmpegInputFormat, type VideoContainer } from '@/lib/video-sniff'
+
+/**
+ * Brings a video shot above 1080p down to 1080p, after it has been saved.
+ *
+ * A clip picked from a phone's library was shot by the phone's own camera at
+ * whatever it was set to, and at 4K a minute of it is hundreds of megabytes
+ * that nobody looking at a worn brake disc needs. The technician's app records
+ * at 1080p itself; this is for everything that did not come from it.
+ *
+ * It runs after the upload has been answered, never during it. Re-encoding 4K
+ * takes minutes, and a phone holding a request open that long with nothing
+ * coming back gives up and reports a failure for a file that arrived. So the
+ * original is saved and served straight away, and the row is pointed at the
+ * smaller copy when there is one.
+ *
+ * Anything that goes wrong leaves the original where it was: no ffmpeg on
+ * this install, a take it cannot read, an attachment deleted in the meantime.
+ */
+
+const execFileAsync = promisify(execFile)
+
+/** The shorter side of a 1080p picture, whichever way the phone was held. */
+export const MAX_SHORT_SIDE = 1080
+
+const PROBE_TIMEOUT_MS = 30_000
+const ENCODE_TIMEOUT_MS = 15 * 60_000
+
+/** Whether a picture of this size is larger than 1080p in either orientation. */
+export function needsDownscale(width: number, height: number): boolean {
+  return Math.min(width, height) > MAX_SHORT_SIDE
+}
+
+/** The picture size out of ffprobe's `WIDTHxHEIGHT` line, or null for anything else. */
+export function parseProbedSize(output: string): { width: number; height: number } | null {
+  const match = /^(\d+)x(\d+)/.exec(output.trim())
+  if (!match) return null
+  const width = Number(match[1])
+  const height = Number(match[2])
+  return width > 0 && height > 0 ? { width, height } : null
+}
+
+/**
+ * The arguments for the re-encode.
+ *
+ * The demuxer is pinned and only local files may be opened, for the reasons
+ * given in video-sniff. The shorter side becomes 1080 and the other follows,
+ * so a clip filmed upright stays upright: ffmpeg applies the phone's rotation
+ * before the filter sees the picture. Eight-bit H.264 with the index at the
+ * front, because that is what every browser and both phones play.
+ */
+export function downscaleArgs(
+  inputPath: string,
+  container: VideoContainer,
+  outputPath: string
+): string[] {
+  const side = MAX_SHORT_SIDE
+  return [
+    '-nostdin',
+    '-protocol_whitelist',
+    'file,crypto',
+    '-f',
+    ffmpegInputFormat(container),
+    '-i',
+    inputPath,
+    '-map',
+    '0:v:0',
+    '-map',
+    '0:a:0?',
+    '-sn',
+    '-dn',
+    '-vf',
+    `scale='if(gt(iw,ih),-2,${side})':'if(gt(iw,ih),${side},-2)'`,
+    '-c:v',
+    'libx264',
+    '-preset',
+    'veryfast',
+    '-crf',
+    '23',
+    '-pix_fmt',
+    'yuv420p',
+    // Two threads: this shares a small machine with the app itself, and a
+    // clip that takes twice as long is better than a workshop that stalls.
+    '-threads',
+    '2',
+    '-c:a',
+    'aac',
+    '-b:a',
+    '128k',
+    '-movflags',
+    '+faststart',
+    '-y',
+    outputPath,
+  ]
+}
+
+async function probeSize(
+  inputPath: string,
+  container: VideoContainer
+): Promise<{ width: number; height: number } | null> {
+  try {
+    const { stdout } = await execFileAsync(
+      'ffprobe',
+      [
+        '-v',
+        'error',
+        '-protocol_whitelist',
+        'file,crypto',
+        '-f',
+        ffmpegInputFormat(container),
+        '-select_streams',
+        'v:0',
+        '-show_entries',
+        'stream=width,height',
+        '-of',
+        'csv=p=0:s=x',
+        inputPath,
+      ],
+      { timeout: PROBE_TIMEOUT_MS }
+    )
+    return parseProbedSize(stdout)
+  } catch {
+    return null
+  }
+}
+
+export interface DownscaleJob {
+  attachmentId: string
+  organizationId: string
+  /** The attachment's URL as it was saved: `/api/protected/files/<org>/services/<name>`. */
+  fileUrl: string
+  container: VideoContainer
+}
+
+const FOLDER = 'services'
+
+async function run(job: DownscaleJob): Promise<void> {
+  const name = job.fileUrl.split('/').pop()
+  if (!name) return
+  const dir = path.join(uploadsRoot(), job.organizationId, FOLDER)
+  const inputPath = path.join(dir, name)
+
+  const size = await probeSize(inputPath, job.container)
+  if (!size || !needsDownscale(size.width, size.height)) return
+
+  const outputName = `${crypto.randomUUID()}.mp4`
+  const outputPath = path.join(dir, outputName)
+
+  let fileSize: number
+  try {
+    await execFileAsync('ffmpeg', downscaleArgs(inputPath, job.container, outputPath), {
+      timeout: ENCODE_TIMEOUT_MS,
+    })
+    fileSize = (await stat(outputPath)).size
+  } catch {
+    // Whatever ffmpeg left behind is half a file that no row points at.
+    await discardUnsavedUpload(job.organizationId, FOLDER, outputName)
+    return
+  }
+
+  // Only if the row still points at the file that was re-encoded. An
+  // attachment deleted while this ran must not come back as a file on disk.
+  const swapped = await db.serviceAttachment.updateMany({
+    where: { id: job.attachmentId, fileUrl: job.fileUrl },
+    data: {
+      fileUrl: `/api/protected/files/${job.organizationId}/${FOLDER}/${outputName}`,
+      fileType: 'video/mp4',
+      fileSize,
+    },
+  })
+  if (swapped.count === 0) {
+    await discardUnsavedUpload(job.organizationId, FOLDER, outputName)
+    return
+  }
+
+  // Through the file manager, not deleted outright: it checks that no other
+  // row picked the original up in the minutes this took.
+  await releaseFiles([job.fileUrl], {
+    organizationId: job.organizationId,
+    reason: 'video replaced by its 1080p copy',
+  })
+}
+
+// On globalThis, so the one queue survives a module being loaded twice.
+const state = globalThis as unknown as { __videoDownscaleQueue?: Promise<void> }
+
+/**
+ * Queues a saved video to be checked and, when it is above 1080p, re-encoded.
+ *
+ * One at a time. Two 4K encodes side by side on the machine that also serves
+ * the workshop is how an upload turns into an outage.
+ */
+export function queueVideoDownscale(job: DownscaleJob): Promise<void> {
+  const next = (state.__videoDownscaleQueue ?? Promise.resolve()).then(() =>
+    run(job).catch((err) => {
+      console.error(`[video] could not downscale attachment ${job.attachmentId}:`, err)
+    })
+  )
+  state.__videoDownscaleQueue = next
+  return next
+}