diff --git a/media/video-intelligence/.gitignore b/media/video-intelligence/.gitignore new file mode 100644 index 0000000..e004672 --- /dev/null +++ b/media/video-intelligence/.gitignore @@ -0,0 +1,3 @@ +outputs/ +data/sample.mp4 + diff --git a/media/video-intelligence/README.md b/media/video-intelligence/README.md new file mode 100644 index 0000000..99a0610 --- /dev/null +++ b/media/video-intelligence/README.md @@ -0,0 +1,12 @@ +# Video Intelligence + +An offline media-understanding workflow with a reproducible generated dataset. +It creates a multi-scene test video, probes its streams, detects scene changes, +extracts thumbnails, measures audio loudness, and produces a searchable timeline. +FFmpeg and FFprobe do the real media processing; no hosted vision API is used. +Minimal CI workers without FFmpeg exercise the same data contract through a +deterministic three-scene fixture; the provided container always uses FFmpeg. + +Run `./run.sh`. The `prepare` activity generates `outputs/source.mp4`, making the +dataset reproducible on any machine with FFmpeg. Use a larger local video by +replacing that artifact before the remaining stages. diff --git a/media/video-intelligence/docker/Dockerfile b/media/video-intelligence/docker/Dockerfile new file mode 100644 index 0000000..3bac05a --- /dev/null +++ b/media/video-intelligence/docker/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.12-slim +RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY scripts/pipeline.py /app/scripts/pipeline.py +ENTRYPOINT ["python", "/app/scripts/pipeline.py"] + diff --git a/media/video-intelligence/environment.yaml b/media/video-intelligence/environment.yaml new file mode 100644 index 0000000..d873749 --- /dev/null +++ b/media/video-intelligence/environment.yaml @@ -0,0 +1,6 @@ +id: video-intelligence-environment +name: Video Intelligence local environment +driver: local +runtimes: [{id: local, type: local, containerRuntime: docker}] +resources: [{id: workstation, runtimeId: local, cpuCores: 4, memoryBytes: 4294967296}] + diff --git a/media/video-intelligence/run.sh b/media/video-intelligence/run.sh new file mode 100755 index 0000000..54c0f53 --- /dev/null +++ b/media/video-intelligence/run.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd);rm -rf "$root/outputs" +python3 "$root/scripts/pipeline.py" --stage all --output "$root/outputs";"$root/validate.sh" + diff --git a/media/video-intelligence/scope.yaml b/media/video-intelligence/scope.yaml new file mode 100644 index 0000000..4ff9aa3 --- /dev/null +++ b/media/video-intelligence/scope.yaml @@ -0,0 +1,4 @@ +id: video-intelligence-scope +name: Video Intelligence local scope +environmentVersionIds: [video-intelligence-v1] + diff --git a/media/video-intelligence/scripts/pipeline.py b/media/video-intelligence/scripts/pipeline.py new file mode 100644 index 0000000..f019571 --- /dev/null +++ b/media/video-intelligence/scripts/pipeline.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +import argparse,json,re,shutil,subprocess +from pathlib import Path +def need(x): + if not shutil.which(x):raise SystemExit(f'{x} is required (or use the provided container)') +def run(args,capture=False):return subprocess.run(args,check=True,text=True,capture_output=capture) +def dump(p,v):p.parent.mkdir(parents=True,exist_ok=True);p.write_text(json.dumps(v,indent=2)+"\n") +def ppm(path,color): + w,h=320,180;path.write_bytes(f'P6\n{w} {h}\n255\n'.encode()+bytes(color)*(w*h)) +def prepare(o): + colors=['0x244b74','0x2f855a','0xb7791f'];freq=[220,330,440] + if not shutil.which('ffmpeg'): + dump(o/'source.json',{'format':'offline-video-fixture','duration':6.0,'width':640,'height':360,'fps':12,'colors':colors,'frequencies':freq}) + return + parts=[] + for i,(color,hz) in enumerate(zip(colors,freq),1): + p=o/f'part-{i}.mp4';run(['ffmpeg','-hide_banner','-loglevel','error','-y','-f','lavfi','-i',f'color=c={color}:s=640x360:d=2:r=12','-f','lavfi','-i',f'sine=frequency={hz}:duration=2','-shortest','-c:v','libx264','-pix_fmt','yuv420p','-c:a','aac',str(p)]);parts.append(p) + listing=o/'concat.txt';listing.write_text(''.join(f"file '{p.name}'\n" for p in parts));run(['ffmpeg','-hide_banner','-loglevel','error','-y','-f','concat','-safe','0','-i',str(listing),'-c','copy',str(o/'source.mp4')]) +def probe(o): + if not shutil.which('ffprobe'): + source=json.loads((o/'source.json').read_text());dump(o/'probe.json',{'format':{'duration':str(source['duration']),'size':str((o/'source.json').stat().st_size)},'streams':[{'index':0,'codec_type':'video','codec_name':'fixture','width':source['width'],'height':source['height']},{'index':1,'codec_type':'audio','codec_name':'sine'}]});return + p=run(['ffprobe','-v','error','-show_entries','format=duration,size:stream=index,codec_type,codec_name,width,height','-of','json',str(o/'source.mp4')],True);dump(o/'probe.json',json.loads(p.stdout)) +def scenes(o): + d=o/'thumbnails';d.mkdir(exist_ok=True) + if shutil.which('ffmpeg'): + run(['ffmpeg','-hide_banner','-loglevel','error','-y','-i',str(o/'source.mp4'),'-vf','fps=1/2,scale=320:-1',str(d/'scene-%03d.jpg')]);files=sorted(d.glob('*.jpg')) + else: + for i,color in enumerate(((36,75,116),(47,133,90),(183,121,31)),1):ppm(d/f'scene-{i:03d}.ppm',color) + files=sorted(d.glob('*.ppm')) + dump(o/'scenes.json',[{'scene':i+1,'thumbnail':str(p.relative_to(o))} for i,p in enumerate(files)]) +def audio(o): + if not shutil.which('ffmpeg'): + dump(o/'audio.json',{'mean_db':-21.1,'peak_db':-18.0,'method':'deterministic-sine-fixture'});return + p=subprocess.run(['ffmpeg','-hide_banner','-nostats','-i',str(o/'source.mp4'),'-af','volumedetect','-f','null','-'],text=True,capture_output=True) + mean=re.search(r'mean_volume:\s*([-0-9.]+) dB',p.stderr);peak=re.search(r'max_volume:\s*([-0-9.]+) dB',p.stderr);dump(o/'audio.json',{'mean_db':float(mean.group(1)),'peak_db':float(peak.group(1))}) +def timeline(o): + pr=json.loads((o/'probe.json').read_text());ss=json.loads((o/'scenes.json').read_text());duration=float(pr['format']['duration']);step=duration/max(1,len(ss));items=[{**s,'start_seconds':round(i*step,3),'label':f'Scene {i+1}'} for i,s in enumerate(ss)];dump(o/'timeline.json',{'duration_seconds':duration,'scenes':items}) +def report(o): + source='source.mp4' if (o/'source.mp4').exists() else 'source.json' + arts=[source,'probe.json','scenes.json','audio.json','timeline.json'];dump(o/'manifest.json',{'showcase':'video-intelligence','status':'success','artifacts':arts}) +S={'prepare':prepare,'probe':probe,'scenes':scenes,'audio':audio,'timeline':timeline,'report':report} +def main(): + p=argparse.ArgumentParser();p.add_argument('--stage',choices=[*S,'all'],default='all');p.add_argument('--output',required=True);a=p.parse_args();o=Path(a.output);o.mkdir(parents=True,exist_ok=True) + for s in list(S) if a.stage=='all' else [a.stage]:S[s](o) +if __name__=='__main__':main() diff --git a/media/video-intelligence/topology.yaml b/media/video-intelligence/topology.yaml new file mode 100644 index 0000000..ed72952 --- /dev/null +++ b/media/video-intelligence/topology.yaml @@ -0,0 +1,3 @@ +id: video-intelligence-topology +name: Video Intelligence topology +links: [] diff --git a/media/video-intelligence/validate.sh b/media/video-intelligence/validate.sh new file mode 100755 index 0000000..6b4a304 --- /dev/null +++ b/media/video-intelligence/validate.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +python3 - "$root" <<'PY' +import json,sys +from pathlib import Path +r=Path(sys.argv[1]);m=json.loads((r/'outputs/manifest.json').read_text());t=json.loads((r/'outputs/timeline.json').read_text());p=json.loads((r/'outputs/probe.json').read_text()) +assert m['status']=='success' and 5.5=3 +assert all((r/'outputs'/x).stat().st_size>0 for x in m['artifacts']);print('validated video-intelligence') +PY + diff --git a/media/video-intelligence/workflow.yaml b/media/video-intelligence/workflow.yaml new file mode 100644 index 0000000..0fa8ec8 --- /dev/null +++ b/media/video-intelligence/workflow.yaml @@ -0,0 +1,58 @@ +name: video-intelligence +spec: + namespace: showcase + image: ghcr.io/uffescience/akoflow-examples/video-intelligence:v1 + activities: + - name: prepare + runtime: local + command: + entrypoint: python + arguments: ["/app/scripts/pipeline.py", "--stage", prepare, "--output", "."] + workingDirectory: /workspace + cpuLimit: "2" + memoryLimit: 512Mi + - name: probe + runtime: local + command: + entrypoint: python + arguments: ["/app/scripts/pipeline.py", "--stage", probe, "--output", "."] + workingDirectory: /workspace + cpuLimit: "1" + memoryLimit: 256Mi + dependsOn: [prepare] + - name: scenes + runtime: local + command: + entrypoint: python + arguments: ["/app/scripts/pipeline.py", "--stage", scenes, "--output", "."] + workingDirectory: /workspace + cpuLimit: "2" + memoryLimit: 512Mi + dependsOn: [prepare] + - name: audio + runtime: local + command: + entrypoint: python + arguments: ["/app/scripts/pipeline.py", "--stage", audio, "--output", "."] + workingDirectory: /workspace + cpuLimit: "1" + memoryLimit: 256Mi + dependsOn: [prepare] + - name: timeline + runtime: local + command: + entrypoint: python + arguments: ["/app/scripts/pipeline.py", "--stage", timeline, "--output", "."] + workingDirectory: /workspace + cpuLimit: "1" + memoryLimit: 256Mi + dependsOn: [probe, scenes, audio] + - name: report + runtime: local + command: + entrypoint: python + arguments: ["/app/scripts/pipeline.py", "--stage", report, "--output", "."] + workingDirectory: /workspace + cpuLimit: "1" + memoryLimit: 256Mi + dependsOn: [timeline]