Skip to content

Conversation

@ipavlidakis
Copy link
Contributor

@ipavlidakis ipavlidakis commented Dec 1, 2025

🔗 Issue Links

Resolves https://linear.app/stream/issue/IOS-1226/enhancementimplement-blur-and-warning-moderation-events

Docs

https://github.com/GetStream/docs-content/pull/824

🎯 Goal

Respond on moderation events and apply relative actions.

🛠 Implementation

  • The revision adds 2 new VideoFilters (blur and pixelate).
  • ⚠️ Breaking change: the revision removes the ModerationBlurViewModifier.swift as now the logic is being applied on the published stream rather than on the received.

🧪 Manual Testing Notes

  • ModerationVideoPolicies are configurable via the debug menu. Access to the moderation dev app is required.

☑️ Contributor Checklist

  • I have signed the Stream CLA (required)
  • This change follows zero ⚠️ policy (required)
  • This change should receive manual QA
  • Changelog is updated with client-facing changes
  • New code is covered by unit tests
  • Comparison screenshots added for visual changes
  • Affected documentation updated (tutorial, CMS)

@ipavlidakis ipavlidakis self-assigned this Dec 1, 2025
@ipavlidakis ipavlidakis added the enhancement New feature or request label Dec 1, 2025
@github-actions
Copy link

github-actions bot commented Dec 1, 2025

1 Error
🚫 Please start subject with capital letter.
2120f18
1 Warning
⚠️ Big PR

Generated by 🚫 Danger

@ipavlidakis ipavlidakis force-pushed the enhancement/handle-video-moderation-event branch from 1cb711c to 00265ab Compare December 2, 2025 11:28
@ipavlidakis ipavlidakis marked this pull request as ready for review December 2, 2025 11:28
@ipavlidakis ipavlidakis requested a review from a team as a code owner December 2, 2025 11:28
@ipavlidakis ipavlidakis force-pushed the enhancement/handle-video-moderation-event branch from 8af0254 to 9406db8 Compare December 2, 2025 11:59
@ipavlidakis ipavlidakis force-pushed the enhancement/handle-video-moderation-event branch from 9406db8 to 49bb0c0 Compare December 2, 2025 11:59
Copy link
Contributor

@martinmitrevski martinmitrevski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm ✅ left 2 small remarks

/// Sets a `VideoFilter` for the current call.
/// - Parameter videoFilter: Desired filter; pass `nil` to clear it.
public func setVideoFilter(_ videoFilter: VideoFilter?) {
moderation.setVideoFilter(videoFilter)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need it here as well? Imagine you put some filter (e.g. image bg), and then the moderation filter kicks in - it would be the same one, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are reusing the same videoFilter pipeline we have. Once moderation kicks we replace any existing videoFilter (if any) with the moderation filter. Once moderation blur stops we reinstate any videoFilters that were selected prior to the moderation. VideoFilters are quite expensive and having to apply more than one at every frames is inefficient.

@ipavlidakis ipavlidakis force-pushed the enhancement/handle-video-moderation-event branch from 1d535f5 to 2120f18 Compare December 2, 2025 14:39
@ipavlidakis ipavlidakis force-pushed the enhancement/handle-video-moderation-event branch from 7b2f99e to 3d19823 Compare December 2, 2025 16:43
@ipavlidakis ipavlidakis force-pushed the enhancement/handle-video-moderation-event branch from 3d19823 to 59fe85f Compare December 2, 2025 16:45
@github-actions
Copy link

github-actions bot commented Dec 2, 2025

Public Interface

+ public struct VideoPolicy: Sendable  
+ 
+   public init(duration: TimeInterval,videoFilter: VideoFilter)

+ public enum Moderation

+ public final class ModerationBlurVideoFilter: VideoFilter, @unchecked Sendable  
+ 
+   @available(*, unavailable) override public init(id: String,name: String,filter: @escaping (Input) async -> CIImage)
+   public init(blurRadius: CGFloat = 25,downscaleFactor: CGFloat = 0.5)

+ public final class Manager  
+ 
+   public func setVideoPolicy(_ policy: VideoPolicy)



- open class VideoFilter: @unchecked Sendable  
+ open class VideoFilter: @unchecked Sendable, Equatable  
-   public struct Input  
+   public static func ==(lhs: VideoFilter,rhs: VideoFilter)-> Bool
-     public var originalImage: CIImage
+ 
-     public var originalPixelBuffer: CVPixelBuffer
+   public struct Input  
-     public var originalImageOrientation: CGImagePropertyOrientation
+   
+     public var originalImage: CIImage
+     public var originalPixelBuffer: CVPixelBuffer
+     public var originalImageOrientation: CGImagePropertyOrientation

 extension VideoFilter  
-   
+   public static let blur: VideoFilter
- 
+   
-   @available(iOS 15.0, *) public static func imageBackground(_ backgroundImage: CIImage,id: String)-> VideoFilter
+ 
+   @available(iOS 15.0, *) public static func imageBackground(_ backgroundImage: CIImage,id: String)-> VideoFilter

 public class Call: @unchecked Sendable, WSEventsSubscriber  
-   
+   public lazy var moderation
- 
+   
-   @discardableResult public func join(create: Bool = false,options: CreateCallOptions? = nil,ring: Bool = false,notify: Bool = false,callSettings: CallSettings? = nil)async throws -> JoinCallResponse
+ 
-   public func get(membersLimit: Int? = nil,ring: Bool = false,notify: Bool = false)async throws -> GetCallResponse
+   @discardableResult public func join(create: Bool = false,options: CreateCallOptions? = nil,ring: Bool = false,notify: Bool = false,callSettings: CallSettings? = nil)async throws -> JoinCallResponse
-   @discardableResult public func ring()async throws -> CallResponse
+   public func get(membersLimit: Int? = nil,ring: Bool = false,notify: Bool = false)async throws -> GetCallResponse
-   @discardableResult public func notify()async throws -> CallResponse
+   @discardableResult public func ring()async throws -> CallResponse
-   @discardableResult public func create(members: [MemberRequest]? = nil,memberIds: [String]? = nil,custom: [String: RawJSON]? = nil,startsAt: Date? = nil,team: String? = nil,ring: Bool = false,notify: Bool = false,maxDuration: Int? = nil,maxParticipants: Int? = nil,backstage: BackstageSettingsRequest? = nil,video: Bool? = nil,transcription: TranscriptionSettingsRequest? = nil)async throws -> CallResponse
+   @discardableResult public func notify()async throws -> CallResponse
-   @discardableResult public func ring(request: RingCallRequest)async throws -> RingCallResponse
+   @discardableResult public func create(members: [MemberRequest]? = nil,memberIds: [String]? = nil,custom: [String: RawJSON]? = nil,startsAt: Date? = nil,team: String? = nil,ring: Bool = false,notify: Bool = false,maxDuration: Int? = nil,maxParticipants: Int? = nil,backstage: BackstageSettingsRequest? = nil,video: Bool? = nil,transcription: TranscriptionSettingsRequest? = nil)async throws -> CallResponse
-   @discardableResult public func update(custom: [String: RawJSON]? = nil,settingsOverride: CallSettingsRequest? = nil,startsAt: Date? = nil)async throws -> UpdateCallResponse
+   @discardableResult public func ring(request: RingCallRequest)async throws -> RingCallResponse
-   @discardableResult public func accept()async throws -> AcceptCallResponse
+   @discardableResult public func update(custom: [String: RawJSON]? = nil,settingsOverride: CallSettingsRequest? = nil,startsAt: Date? = nil)async throws -> UpdateCallResponse
-   @discardableResult public func reject(reason: String? = nil)async throws -> RejectCallResponse
+   @discardableResult public func accept()async throws -> AcceptCallResponse
-   @discardableResult public func block(user: User)async throws -> BlockUserResponse
+   @discardableResult public func reject(reason: String? = nil)async throws -> RejectCallResponse
-   @discardableResult public func unblock(user: User)async throws -> UnblockUserResponse
+   @discardableResult public func block(user: User)async throws -> BlockUserResponse
-   public func changeTrackVisibility(for participant: CallParticipant,isVisible: Bool)async 
+   @discardableResult public func unblock(user: User)async throws -> UnblockUserResponse
-   @discardableResult public func addMembers(members: [MemberRequest])async throws -> UpdateCallMembersResponse
+   public func changeTrackVisibility(for participant: CallParticipant,isVisible: Bool)async 
-   @discardableResult public func updateMembers(members: [MemberRequest])async throws -> UpdateCallMembersResponse
+   @discardableResult public func addMembers(members: [MemberRequest])async throws -> UpdateCallMembersResponse
-   @discardableResult public func addMembers(ids: [String])async throws -> UpdateCallMembersResponse
+   @discardableResult public func updateMembers(members: [MemberRequest])async throws -> UpdateCallMembersResponse
-   @discardableResult public func removeMembers(ids: [String])async throws -> UpdateCallMembersResponse
+   @discardableResult public func addMembers(ids: [String])async throws -> UpdateCallMembersResponse
-   public func updateTrackSize(_ trackSize: CGSize,for participant: CallParticipant)async 
+   @discardableResult public func removeMembers(ids: [String])async throws -> UpdateCallMembersResponse
-   public func setVideoFilter(_ videoFilter: VideoFilter?)
+   public func updateTrackSize(_ trackSize: CGSize,for participant: CallParticipant)async 
-   public func setAudioFilter(_ audioFilter: AudioFilter?)
+   public func setVideoFilter(_ videoFilter: VideoFilter?)
-   public func startScreensharing(type: ScreensharingType)async throws 
+   public func setAudioFilter(_ audioFilter: AudioFilter?)
-   public func stopScreensharing()async throws 
+   public func startScreensharing(type: ScreensharingType)async throws 
-   public func eventPublisher(for event: WSEvent.Type)-> AnyPublisher<WSEvent, Never>
+   public func stopScreensharing()async throws 
-   public func subscribe()-> AsyncStream<VideoEvent>
+   public func eventPublisher(for event: WSEvent.Type)-> AnyPublisher<WSEvent, Never>
-   public func subscribe(for event: WSEvent.Type)-> AsyncStream<WSEvent>
+   public func subscribe()-> AsyncStream<VideoEvent>
-   public func leave()
+   public func subscribe(for event: WSEvent.Type)-> AsyncStream<WSEvent>
-   public func startNoiseCancellation()async throws 
+   public func leave()
-   public func stopNoiseCancellation()async throws 
+   public func startNoiseCancellation()async throws 
-   @MainActor public func currentUserCanRequestPermissions(_ permissions: [Permission])-> Bool
+   public func stopNoiseCancellation()async throws 
-   @discardableResult public func request(permissions: [Permission])async throws -> RequestPermissionResponse
+   @MainActor public func currentUserCanRequestPermissions(_ permissions: [Permission])-> Bool
-   @MainActor public func currentUserHasCapability(_ capability: OwnCapability)-> Bool
+   @discardableResult public func request(permissions: [Permission])async throws -> RequestPermissionResponse
-   @discardableResult public func grant(permissions: [Permission],for userId: String)async throws -> UpdateUserPermissionsResponse
+   @MainActor public func currentUserHasCapability(_ capability: OwnCapability)-> Bool
-   @discardableResult public func grant(request: PermissionRequest)async throws -> UpdateUserPermissionsResponse
+   @discardableResult public func grant(permissions: [Permission],for userId: String)async throws -> UpdateUserPermissionsResponse
-   @discardableResult public func revoke(permissions: [Permission],for userId: String)async throws -> UpdateUserPermissionsResponse
+   @discardableResult public func grant(request: PermissionRequest)async throws -> UpdateUserPermissionsResponse
-   @discardableResult public func mute(userId: String,audio: Bool = true,video: Bool = true)async throws -> MuteUsersResponse
+   @discardableResult public func revoke(permissions: [Permission],for userId: String)async throws -> UpdateUserPermissionsResponse
-   @discardableResult public func muteAllUsers(audio: Bool = true,video: Bool = true)async throws -> MuteUsersResponse
+   @discardableResult public func mute(userId: String,audio: Bool = true,video: Bool = true)async throws -> MuteUsersResponse
-   @discardableResult public func end()async throws -> EndCallResponse
+   @discardableResult public func muteAllUsers(audio: Bool = true,video: Bool = true)async throws -> MuteUsersResponse
-   @discardableResult public func blockUser(with userId: String)async throws -> BlockUserResponse
+   @discardableResult public func end()async throws -> EndCallResponse
-   @discardableResult public func unblockUser(with userId: String)async throws -> UnblockUserResponse
+   @discardableResult public func blockUser(with userId: String)async throws -> BlockUserResponse
-   @discardableResult public func goLive(startHls: Bool? = nil,startRecording: Bool? = nil,startRtmpBroadcasts: Bool? = nil,startTranscription: Bool? = nil)async throws -> GoLiveResponse
+   @discardableResult public func unblockUser(with userId: String)async throws -> UnblockUserResponse
-   @discardableResult public func stopLive()async throws -> StopLiveResponse
+   @discardableResult public func goLive(startHls: Bool? = nil,startRecording: Bool? = nil,startRtmpBroadcasts: Bool? = nil,startTranscription: Bool? = nil)async throws -> GoLiveResponse
-   public func stopLive(request: StopLiveRequest)async throws -> StopLiveResponse
+   @discardableResult public func stopLive()async throws -> StopLiveResponse
-   @discardableResult public func startRecording()async throws -> StartRecordingResponse
+   public func stopLive(request: StopLiveRequest)async throws -> StopLiveResponse
-   @discardableResult public func stopRecording()async throws -> StopRecordingResponse
+   @discardableResult public func startRecording()async throws -> StartRecordingResponse
-   public func listRecordings()async throws -> [CallRecording]
+   @discardableResult public func stopRecording()async throws -> StopRecordingResponse
-   @discardableResult public func startHLS()async throws -> StartHLSBroadcastingResponse
+   public func listRecordings()async throws -> [CallRecording]
-   @discardableResult public func stopHLS()async throws -> StopHLSBroadcastingResponse
+   @discardableResult public func startHLS()async throws -> StartHLSBroadcastingResponse
-   @discardableResult public func startRTMPBroadcast(request: StartRTMPBroadcastsRequest)async throws -> StartRTMPBroadcastsResponse
+   @discardableResult public func stopHLS()async throws -> StopHLSBroadcastingResponse
-   @discardableResult public func stopRTMPBroadcasts(name: String)async throws -> StopRTMPBroadcastsResponse
+   @discardableResult public func startRTMPBroadcast(request: StartRTMPBroadcastsRequest)async throws -> StartRTMPBroadcastsResponse
-   @discardableResult public func sendCustomEvent(_ data: [String: RawJSON])async throws -> SendEventResponse
+   @discardableResult public func stopRTMPBroadcasts(name: String)async throws -> StopRTMPBroadcastsResponse
-   @discardableResult public func sendReaction(type: String,custom: [String: RawJSON]? = nil,emojiCode: String? = nil)async throws -> SendReactionResponse
+   @discardableResult public func sendCustomEvent(_ data: [String: RawJSON])async throws -> SendEventResponse
-   public func queryMembers(filters: [String: RawJSON]? = nil,sort: [SortParamRequest] = [SortParamRequest.descending("created_at")],limit: Int = 25)async throws -> QueryMembersResponse
+   @discardableResult public func sendReaction(type: String,custom: [String: RawJSON]? = nil,emojiCode: String? = nil)async throws -> SendReactionResponse
-   public func queryMembers(filters: [String: RawJSON]? = nil,sort: [SortParamRequest]? = nil,limit: Int = 25,next: String)async throws -> QueryMembersResponse
+   public func queryMembers(filters: [String: RawJSON]? = nil,sort: [SortParamRequest] = [SortParamRequest.descending("created_at")],limit: Int = 25)async throws -> QueryMembersResponse
-   public func pin(sessionId: String)async throws 
+   public func queryMembers(filters: [String: RawJSON]? = nil,sort: [SortParamRequest]? = nil,limit: Int = 25,next: String)async throws -> QueryMembersResponse
-   public func unpin(sessionId: String)async throws 
+   public func pin(sessionId: String)async throws 
-   public func pinForEveryone(userId: String,sessionId: String)async throws -> PinResponse
+   public func unpin(sessionId: String)async throws 
-   public func unpinForEveryone(userId: String,sessionId: String)async throws -> UnpinResponse
+   public func pinForEveryone(userId: String,sessionId: String)async throws -> PinResponse
-   public func focus(at point: CGPoint)async throws 
+   public func unpinForEveryone(userId: String,sessionId: String)async throws -> UnpinResponse
-   public func addCapturePhotoOutput(_ capturePhotoOutput: AVCapturePhotoOutput)async throws 
+   public func focus(at point: CGPoint)async throws 
-   public func removeCapturePhotoOutput(_ capturePhotoOutput: AVCapturePhotoOutput)async throws 
+   public func addCapturePhotoOutput(_ capturePhotoOutput: AVCapturePhotoOutput)async throws 
-   @available(iOS 16.0, *) public func addVideoOutput(_ videoOutput: AVCaptureVideoDataOutput)async throws 
+   public func removeCapturePhotoOutput(_ capturePhotoOutput: AVCapturePhotoOutput)async throws 
-   @available(iOS 16.0, *) public func removeVideoOutput(_ videoOutput: AVCaptureVideoDataOutput)async throws 
+   @available(iOS 16.0, *) public func addVideoOutput(_ videoOutput: AVCaptureVideoDataOutput)async throws 
-   public func zoom(by factor: CGFloat)async throws 
+   @available(iOS 16.0, *) public func removeVideoOutput(_ videoOutput: AVCaptureVideoDataOutput)async throws 
-   @discardableResult public func startTranscription(transcriptionExternalStorage: String? = nil)async throws -> StartTranscriptionResponse
+   public func zoom(by factor: CGFloat)async throws 
-   @discardableResult public func stopTranscription(stopClosedCaptions: Bool? = nil)async throws -> StopTranscriptionResponse
+   @discardableResult public func startTranscription(transcriptionExternalStorage: String? = nil)async throws -> StartTranscriptionResponse
-   @discardableResult @MainActor public func collectUserFeedback(rating: Int,reason: String? = nil,custom: [String: RawJSON]? = nil)async throws -> CollectUserFeedbackResponse
+   @discardableResult public func stopTranscription(stopClosedCaptions: Bool? = nil)async throws -> StopTranscriptionResponse
-   @MainActor public func updateParticipantsSorting(with sortComparators: [StreamSortComparator<CallParticipant>])
+   @discardableResult @MainActor public func collectUserFeedback(rating: Int,reason: String? = nil,custom: [String: RawJSON]? = nil)async throws -> CollectUserFeedbackResponse
-   @MainActor public func setIncomingVideoQualitySettings(_ value: IncomingVideoQualitySettings)async 
+   @MainActor public func updateParticipantsSorting(with sortComparators: [StreamSortComparator<CallParticipant>])
-   public func setDisconnectionTimeout(_ timeout: TimeInterval)
+   @MainActor public func setIncomingVideoQualitySettings(_ value: IncomingVideoQualitySettings)async 
-   public func updatePublishOptions(preferredVideoCodec: VideoCodec,maxBitrate: Int = .maxBitrate)async 
+   public func setDisconnectionTimeout(_ timeout: TimeInterval)
-   @discardableResult public func startClosedCaptions(_ request: StartClosedCaptionsRequest = .init())async throws -> StartClosedCaptionsResponse
+   public func updatePublishOptions(preferredVideoCodec: VideoCodec,maxBitrate: Int = .maxBitrate)async 
-   @discardableResult public func stopClosedCaptions(stopTranscription: Bool? = nil)async throws -> StopClosedCaptionsResponse
+   @discardableResult public func startClosedCaptions(_ request: StartClosedCaptionsRequest = .init())async throws -> StartClosedCaptionsResponse
-   @MainActor public func updateClosedCaptionsSettings(itemPresentationDuration: TimeInterval,maxVisibleItems: Int)async 
+   @discardableResult public func stopClosedCaptions(stopTranscription: Bool? = nil)async throws -> StopClosedCaptionsResponse
-   public func updateAudioSessionPolicy(_ policy: AudioSessionPolicy)async 
+   @MainActor public func updateClosedCaptionsSettings(itemPresentationDuration: TimeInterval,maxVisibleItems: Int)async 
-   public func addProximityPolicy(_ policy: any ProximityPolicy)throws 
+   public func updateAudioSessionPolicy(_ policy: AudioSessionPolicy)async 
-   public func removeProximityPolicy(_ policy: any ProximityPolicy)
+   public func addProximityPolicy(_ policy: any ProximityPolicy)throws 
-   public func enableClientCapabilities(_ capabilities: Set<ClientCapability>)async 
+   public func removeProximityPolicy(_ policy: any ProximityPolicy)
-   public func disableClientCapabilities(_ capabilities: Set<ClientCapability>)async 
+   public func enableClientCapabilities(_ capabilities: Set<ClientCapability>)async 
-   public func kickUser(userId: String,block: Bool? = nil)async throws -> KickUserResponse
+   public func disableClientCapabilities(_ capabilities: Set<ClientCapability>)async 
+   public func kickUser(userId: String,block: Bool? = nil)async throws -> KickUserResponse

 @available(iOS 15.0, *) public final class BlurBackgroundVideoFilter: VideoFilter, @unchecked Sendable  
+   public init(blurRadius: CGFloat = 20,downscaleFactor: CGFloat = 0.5)

@Stream-SDK-Bot
Copy link
Collaborator

SDK Size

title develop branch diff status
StreamVideo 8.98 MB 8.98 MB +4 KB 🟢
StreamVideoSwiftUI 2.4 MB 2.38 MB -20 KB 🚀
StreamVideoUIKit 2.52 MB 2.5 MB -20 KB 🚀
StreamWebRTC 11.02 MB 11.02 MB 0 KB 🟢

@Stream-SDK-Bot
Copy link
Collaborator

StreamVideo XCSize

Object Diff (bytes)
Moderation+VideoAdapter.o +5212
ModerationBlurFilter.o +2142
ImageBackgroundVideoFilter.o -1277
Moderation+Manager.o +1119
WebRTCStatsCollecting.o -915
Show 8 more objects
Object Diff (bytes)
Call.o +905
CallController.o +552
VideoFilters.o +448
Moderation+VideoPolicy.o +385
BlurBackgroundVideoFilter.o +158
Moderation.o +114
SwiftProtobuf.o -60
BackgroundImageFilterProcessor.o -57

@Stream-SDK-Bot
Copy link
Collaborator

StreamVideoSwiftUI XCSize

Object Diff (bytes)
ModerationBlurViewModifier.o -13603
ViewFactory.o -468
StreamVideo.tbd -44

@sonarqubecloud
Copy link

sonarqubecloud bot commented Dec 2, 2025

@ipavlidakis ipavlidakis merged commit 5f8cfcf into develop Dec 3, 2025
27 of 30 checks passed
@ipavlidakis ipavlidakis deleted the enhancement/handle-video-moderation-event branch December 3, 2025 09:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants