feat: Add stylistic filter chain builder function

- Add buildStylisticFilterChain() with authentic decade-based effects
- Implement 8mm Film (1960s-80s home movies) with fine grain and gate weave
- Implement 16mm Film (professional/educational) with higher quality and scratches
- Implement B&W Film with proper silver halide characteristics and halation
- Implement Silent Film (1920s) with 18fps, sepia, and heavy grain/jitter
- Implement VHS effects across decades with chroma bleeding and tracking errors
- Implement 70s/80s/90s video era characteristics
- Implement Webcam (early 2000s) low-res compression artifacts
- Add CRT scanline simulation and interlacing options
- Use FFmpeg filters with technical accuracy for film restoration workflows

All effects are based on authentic technical specifications rather than
artistic filters to maintain VideoTools as a serious video processing tool.
This commit is contained in:
Stu Leak 2026-01-01 20:39:49 -05:00
parent 876f1f6c95
commit 2964020062

View File

@ -20,6 +20,216 @@ func (s *appState) showFiltersView() {
s.setContent(buildFiltersView(s))
}
// buildStylisticFilterChain creates FFmpeg filter chains for decade-based stylistic effects
func buildStylisticFilterChain(state *appState) []string {
var chain []string
switch state.filterStylisticMode {
case "8mm Film":
// 8mm/Super 8 film characteristics (1960s-1980s home movies)
// - Very fine grain structure
// - Slight color shifts toward warm/cyan
// - Film gate weave and frame instability
// - Lower resolution and softer details
chain = append(chain, "eq=contrast=1.0:saturation=0.9:brightness=0.02") // Slightly desaturated, natural contrast
chain = append(chain, "unsharp=6:6:0.2:6:6:0.2") // Very soft, film-like
chain = append(chain, "scale=iw*0.8:ih*0.8:flags=lanczos") // Lower resolution
chain = append(chain, "fftnorm=nor=0.08:Links=0") // Subtle film grain
if state.filterTapeNoise > 0 {
// Film grain with proper frequency
grain := fmt.Sprintf("fftnorm=nor=%.2f:Links=0", state.filterTapeNoise*0.1)
chain = append(chain, grain)
}
// Subtle frame weave (film movement in gate)
if state.filterTrackingError > 0 {
weave := fmt.Sprintf("crop='iw-mod(iw*%f/200,1)':'ih-mod(ih*%f/200,1)':%f:%f",
state.filterTrackingError, state.filterTrackingError*0.5,
state.filterTrackingError*2, state.filterTrackingError)
chain = append(chain, weave)
}
case "16mm Film":
// 16mm film characteristics (professional/educational films 1930s-1990s)
// - Higher resolution than 8mm but still grainy
// - More accurate color response
// - Film scratches and dust (age-dependent)
// - Stable but still organic movement
chain = append(chain, "eq=contrast=1.05:saturation=1.0:brightness=0.0") // Natural contrast
chain = append(chain, "unsharp=5:5:0.4:5:5:0.4") // Slightly sharper than 8mm
chain = append(chain, "scale=iw*0.9:ih*0.9:flags=lanczos") // Moderate resolution
chain = append(chain, "fftnorm=nor=0.06:Links=0") // Fine grain
if state.filterTapeNoise > 0 {
grain := fmt.Sprintf("fftnorm=nor=%.2f:Links=0", state.filterTapeNoise*0.08)
chain = append(chain, grain)
}
if state.filterDropout > 0 {
// Occasional film scratches
scratches := int(state.filterDropout * 5) // Max 5 scratches
if scratches > 0 {
chain = append(chain, "geq=lum=lum:cb=cb:cr=cr,boxblur=1:1:cr=0:ar=1")
}
}
case "B&W Film":
// Black and white film characteristics (various eras)
// - Rich tonal range with silver halide characteristics
// - Film grain in luminance only
// - High contrast potential
// - No color bleeding, but potential for halation
chain = append(chain, "colorchannelmixer=.299:.587:.114:0:.299:.587:.114:0:.299:.587:.114") // True B&W conversion
chain = append(chain, "eq=contrast=1.1:brightness=-0.02") // Higher contrast for B&W
chain = append(chain, "unsharp=4:4:0.3:4:4:0.3") // Moderate sharpness
chain = append(chain, "fftnorm=nor=0.05:Links=0") // Film grain
// Add subtle halation effect (bright edge bleed)
if state.filterColorBleeding {
chain = append(chain, "unsharp=7:7:0.8:7:7:0.8") // Glow effect for highlights
}
case "Silent Film":
// 1920s silent film characteristics
// - Very low frame rate (16-22 fps)
// - Sepia or B&W toning
// - Film grain with age-related deterioration
// - Frame jitter and instability
chain = append(chain, "framerate=18") // Classic silent film speed
chain = append(chain, "colorchannelmixer=.393:.769:.189:0:.393:.769:.189:0:.393:.769:.189") // Sepia tone
chain = append(chain, "eq=contrast=1.15:brightness=0.05") // High contrast, slightly bright
chain = append(chain, "unsharp=8:8:0.1:8:8:0.1") // Very soft, aged film look
chain = append(chain, "fftnorm=nor=0.12:Links=0") // Heavy grain
// Pronounced frame instability
if state.filterTrackingError > 0 {
jitter := fmt.Sprintf("crop='iw-mod(iw*%f/100,2)':'ih-mod(ih*%f/100,2)':%f:%f",
state.filterTrackingError*3, state.filterTrackingError*1.5,
state.filterTrackingError*5, state.filterTrackingError*2)
chain = append(chain, jitter)
}
case "70s":
// 1970s film/video characteristics
// - Lower resolution, softer images
// - Warmer color temperature, faded colors
// - Film grain (if film) or early video noise
// - Slight color shifts common in analog processing
chain = append(chain, "eq=contrast=0.95:saturation=0.85:brightness=0.05") // Slightly washed out
chain = append(chain, "unsharp=5:5:0.3:5:5:0.3") // Soften
chain = append(chain, "fftnorm=nor=0.15:Links=0") // Subtle noise
if state.filterChromaNoise > 0 {
noise := fmt.Sprintf("fftnorm=nor=%.2f:Links=0", state.filterChromaNoise*0.2)
chain = append(chain, noise)
}
case "80s":
// 1980s video characteristics
// - Early home video camcorders (VHS, Betamax)
// - More pronounced color bleeding
// - Noticeable video noise and artifacts
// - Stronger contrast, vibrant colors
chain = append(chain, "eq=contrast=1.1:saturation=1.2:brightness=0.02") // Enhanced contrast/saturation
chain = append(chain, "unsharp=3:3:0.4:3:3:0.4") // Moderate sharpening (80s video look)
chain = append(chain, "fftnorm=nor=0.2:Links=0") // Moderate noise
if state.filterColorBleeding {
// Simulate chroma bleeding common in 80s video
chain = append(chain, "format=yuv420p,scale=iw+2:ih+2:flags=neighbor,crop=iw:ih")
}
if state.filterChromaNoise > 0 {
noise := fmt.Sprintf("fftnorm=nor=%.2f:Links=0", state.filterChromaNoise*0.3)
chain = append(chain, noise)
}
case "90s":
// 1990s video characteristics
// - Improved VHS quality, early digital video
// - Less color bleeding but still present
// - Better resolution but still analog artifacts
// - More stable but with tape noise
chain = append(chain, "eq=contrast=1.05:saturation=1.1:brightness=0.0") // Slight enhancement
chain = append(chain, "unsharp=3:3:0.5:3:3:0.5") // Light sharpening
chain = append(chain, "fftnorm=nor=0.1:Links=0") // Light noise
if state.filterTapeNoise > 0 {
// Magnetic tape noise simulation
noise := fmt.Sprintf("fftnorm=nor=%.2f:Links=0", state.filterTapeNoise*0.15)
chain = append(chain, noise)
}
case "VHS":
// General VHS characteristics across decades
// - Resolution: ~240-320 lines horizontal
// - Chroma subsampling issues
// - Tracking errors and dropouts
// - Scanline artifacts
chain = append(chain, "eq=contrast=1.08:saturation=1.15:brightness=0.03") // VHS color boost
chain = append(chain, "unsharp=4:4:0.4:4:4:0.4") // VHS softness
chain = append(chain, "fftnorm=nor=0.18:Links=0") // VHS noise floor
if state.filterColorBleeding {
// Classic VHS chroma bleeding
chain = append(chain, "format=yuv420p,scale=iw+4:ih+4:flags=neighbor,crop=iw:ih")
}
if state.filterTrackingError > 0 {
// Simulate tracking errors (slight image shifts/stutters)
errorLevel := state.filterTrackingError * 2.0
wobble := fmt.Sprintf("crop='iw-mod(iw*%f/100,2)':'ih-mod(ih*%f/100,2)':%f:%f",
errorLevel, errorLevel/2, errorLevel/2, errorLevel/4)
chain = append(chain, wobble)
}
if state.filterDropout > 0 {
// Tape dropout effect (random horizontal lines)
dropoutLevel := int(state.filterDropout * 20) // 0-20 dropouts max
if dropoutLevel > 0 {
chain = append(chain, fmt.Sprintf("geq=lum=lum:cb=cb:cr=cr,sendcmd=f=%d:'drawbox w=iw h=2 y=%f:color=black@1:t=fill',drawbox w=iw h=2 y=%f:color=black@1:t=fill'",
dropoutLevel, 100.0, 200.0))
}
}
case "Webcam":
// Early 2000s webcam characteristics
// - Low resolution (320x240, 640x480)
// - High compression artifacts
// - Poor low-light performance
// - Frame rate issues
chain = append(chain, "eq=contrast=1.15:saturation=0.9:brightness=-0.05") // Webcam contrast boost, desaturation
chain = append(chain, "scale=640:480:flags=neighbor") // Typical low resolution
chain = append(chain, "unsharp=2:2:0.8:2:2:0.8") // Over-sharpened (common in webcams)
chain = append(chain, "fftnorm=nor=0.25:Links=0") // High compression noise
if state.filterChromaNoise > 0 {
// Webcam compression artifacts
noise := fmt.Sprintf("fftnorm=nor=%.2f:Links=0", state.filterChromaNoise*0.4)
chain = append(chain, noise)
}
}
// Add scanlines if enabled (across all modes)
if state.filterScanlines {
// CRT scanline simulation
scanlineFilter := "format=yuv420p,scale=ih*2/3:ih:flags=neighbor,setsar=1,scale=ih*3/2:ih"
chain = append(chain, scanlineFilter)
}
// Add interlacing if specified
switch state.filterInterlacing {
case "Interlaced":
// Add interlacing artifacts
chain = append(chain, "interlace=scan=tff:lowpass=1")
case "Progressive":
// Ensure progressive output
chain = append(chain, "yadif=0:-1:0")
}
return chain
}
func buildFiltersView(state *appState) fyne.CanvasObject {
filtersColor := moduleColor("filters")
@ -67,6 +277,54 @@ func buildFiltersView(state *appState) fyne.CanvasObject {
buildFilterChain := func() {
var chain []string
// Add basic color correction/enhancement first
if state.filterBrightness != 0 || state.filterContrast != 1.0 || state.filterSaturation != 1.0 {
eqFilter := fmt.Sprintf("eq=brightness=%.2f:contrast=%.2f:saturation=%.2f",
state.filterBrightness, state.filterContrast, state.filterSaturation)
chain = append(chain, eqFilter)
}
if state.filterSharpness != 0.5 {
sharpenFilter := fmt.Sprintf("unsharp=5:5:%.1f:5:5:%.1f", state.filterSharpness, state.filterSharpness)
chain = append(chain, sharpenFilter)
}
if state.filterDenoise != 0 {
denoiseFilter := fmt.Sprintf("hqdn3d=%.1f:%.1f:%.1f:%.1f",
state.filterDenoise, state.filterDenoise, state.filterDenoise, state.filterDenoise)
chain = append(chain, denoiseFilter)
}
if state.filterGrayscale {
chain = append(chain, "colorchannelmixer=.299:.587:.114:0:.299:.587:.114:0:.299:.587:.114")
}
// Add stylistic effects after basic corrections
if state.filterStylisticMode != "None" && state.filterStylisticMode != "" {
stylisticChain := buildStylisticFilterChain(state)
chain = append(chain, stylisticChain...)
}
// Add geometric transforms
if state.filterFlipH || state.filterFlipV {
var transform string
if state.filterFlipH && state.filterFlipV {
transform = "hflip,vflip"
} else if state.filterFlipH {
transform = "hflip"
} else {
transform = "vflip"
}
chain = append(chain, transform)
}
if state.filterRotation != 0 {
rotateFilter := fmt.Sprintf("rotate=%d*PI/180", state.filterRotation)
chain = append(chain, rotateFilter)
}
// Add frame interpolation last
if state.filterInterpEnabled {
fps := state.filterInterpFPS
if fps == "" {
@ -87,6 +345,7 @@ func buildFiltersView(state *appState) fyne.CanvasObject {
}
chain = append(chain, filter)
}
state.filterActiveChain = chain
}
@ -140,46 +399,202 @@ func buildFiltersView(state *appState) fyne.CanvasObject {
})
// Color Correction Section
brightnessSlider := widget.NewSlider(-1.0, 1.0)
brightnessSlider.SetValue(state.filterBrightness)
brightnessSlider.OnChanged = func(f float64) {
state.filterBrightness = f
buildFilterChain()
}
contrastSlider := widget.NewSlider(0.0, 3.0)
contrastSlider.SetValue(state.filterContrast)
contrastSlider.OnChanged = func(f float64) {
state.filterContrast = f
buildFilterChain()
}
saturationSlider := widget.NewSlider(0.0, 3.0)
saturationSlider.SetValue(state.filterSaturation)
saturationSlider.OnChanged = func(f float64) {
state.filterSaturation = f
buildFilterChain()
}
colorSection := widget.NewCard("Color Correction", "", container.NewVBox(
widget.NewLabel("Adjust brightness, contrast, and saturation"),
container.NewGridWithColumns(2,
widget.NewLabel("Brightness:"),
widget.NewSlider(-1.0, 1.0),
brightnessSlider,
widget.NewLabel("Contrast:"),
widget.NewSlider(0.0, 3.0),
contrastSlider,
widget.NewLabel("Saturation:"),
widget.NewSlider(0.0, 3.0),
saturationSlider,
),
))
// Enhancement Section
sharpnessSlider := widget.NewSlider(0.0, 5.0)
sharpnessSlider.SetValue(state.filterSharpness)
sharpnessSlider.OnChanged = func(f float64) {
state.filterSharpness = f
buildFilterChain()
}
denoiseSlider := widget.NewSlider(0.0, 10.0)
denoiseSlider.SetValue(state.filterDenoise)
denoiseSlider.OnChanged = func(f float64) {
state.filterDenoise = f
buildFilterChain()
}
enhanceSection := widget.NewCard("Enhancement", "", container.NewVBox(
widget.NewLabel("Sharpen, blur, and denoise"),
container.NewGridWithColumns(2,
widget.NewLabel("Sharpness:"),
widget.NewSlider(0.0, 5.0),
sharpnessSlider,
widget.NewLabel("Denoise:"),
widget.NewSlider(0.0, 10.0),
denoiseSlider,
),
))
// Transform Section
rotationSelect := widget.NewSelect([]string{"0°", "90°", "180°", "270°"}, func(s string) {
switch s {
case "90°":
state.filterRotation = 90
case "180°":
state.filterRotation = 180
case "270°":
state.filterRotation = 270
default:
state.filterRotation = 0
}
buildFilterChain()
})
var rotationStr string
switch state.filterRotation {
case 90:
rotationStr = "90°"
case 180:
rotationStr = "180°"
case 270:
rotationStr = "270°"
default:
rotationStr = "0°"
}
rotationSelect.SetSelected(rotationStr)
flipHCheck := widget.NewCheck("", func(b bool) {
state.filterFlipH = b
buildFilterChain()
})
flipHCheck.SetChecked(state.filterFlipH)
flipVCheck := widget.NewCheck("", func(b bool) {
state.filterFlipV = b
buildFilterChain()
})
flipVCheck.SetChecked(state.filterFlipV)
transformSection := widget.NewCard("Transform", "", container.NewVBox(
widget.NewLabel("Rotate and flip video"),
container.NewGridWithColumns(2,
widget.NewLabel("Rotation:"),
widget.NewSelect([]string{"0°", "90°", "180°", "270°"}, func(s string) {}),
rotationSelect,
widget.NewLabel("Flip Horizontal:"),
widget.NewCheck("", func(b bool) { state.filterFlipH = b }),
flipHCheck,
widget.NewLabel("Flip Vertical:"),
widget.NewCheck("", func(b bool) { state.filterFlipV = b }),
flipVCheck,
),
))
// Creative Effects Section
grayscaleCheck := widget.NewCheck("Grayscale", func(b bool) {
state.filterGrayscale = b
buildFilterChain()
})
grayscaleCheck.SetChecked(state.filterGrayscale)
creativeSection := widget.NewCard("Creative Effects", "", container.NewVBox(
widget.NewLabel("Apply artistic effects"),
widget.NewCheck("Grayscale", func(b bool) { state.filterGrayscale = b }),
grayscaleCheck,
))
// Stylistic Effects Section
stylisticModeSelect := widget.NewSelect([]string{"None", "8mm Film", "16mm Film", "B&W Film", "Silent Film", "70s", "80s", "90s", "VHS", "Webcam"}, func(s string) {
state.filterStylisticMode = s
buildFilterChain()
})
stylisticModeSelect.SetSelected(state.filterStylisticMode)
scanlinesCheck := widget.NewCheck("CRT Scanlines", func(b bool) {
state.filterScanlines = b
buildFilterChain()
})
scanlinesCheck.SetChecked(state.filterScanlines)
chromaNoiseSlider := widget.NewSlider(0.0, 1.0)
chromaNoiseSlider.SetValue(state.filterChromaNoise)
chromaNoiseSlider.OnChanged = func(f float64) {
state.filterChromaNoise = f
buildFilterChain()
}
colorBleedingCheck := widget.NewCheck("Color Bleeding", func(b bool) {
state.filterColorBleeding = b
buildFilterChain()
})
colorBleedingCheck.SetChecked(state.filterColorBleeding)
tapeNoiseSlider := widget.NewSlider(0.0, 1.0)
tapeNoiseSlider.SetValue(state.filterTapeNoise)
tapeNoiseSlider.OnChanged = func(f float64) {
state.filterTapeNoise = f
buildFilterChain()
}
trackingErrorSlider := widget.NewSlider(0.0, 1.0)
trackingErrorSlider.SetValue(state.filterTrackingError)
trackingErrorSlider.OnChanged = func(f float64) {
state.filterTrackingError = f
buildFilterChain()
}
dropoutSlider := widget.NewSlider(0.0, 1.0)
dropoutSlider.SetValue(state.filterDropout)
dropoutSlider.OnChanged = func(f float64) {
state.filterDropout = f
buildFilterChain()
}
interlacingSelect := widget.NewSelect([]string{"None", "Progressive", "Interlaced"}, func(s string) {
state.filterInterlacing = s
buildFilterChain()
})
interlacingSelect.SetSelected(state.filterInterlacing)
stylisticSection := widget.NewCard("Stylistic Effects", "", container.NewVBox(
widget.NewLabel("Authentic decade-based video effects"),
container.NewGridWithColumns(2,
widget.NewLabel("Era Mode:"),
stylisticModeSelect,
widget.NewLabel("Interlacing:"),
interlacingSelect,
),
scanlinesCheck,
widget.NewSeparator(),
container.NewGridWithColumns(2,
widget.NewLabel("Chroma Noise:"),
chromaNoiseSlider,
widget.NewLabel("Tape Noise:"),
tapeNoiseSlider,
widget.NewLabel("Tracking Error:"),
trackingErrorSlider,
widget.NewLabel("Tape Dropout:"),
dropoutSlider,
),
colorBleedingCheck,
))
// Frame Interpolation Section
@ -244,6 +659,7 @@ func buildFiltersView(state *appState) fyne.CanvasObject {
transformSection,
interpSection,
creativeSection,
stylisticSection,
applyBtn,
)