Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion mobile/docs/VIDEO_EDITOR_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Clip actions ([`video_editor_timeline_clip_controls.dart`](../lib/widgets/video_
- **Trim** with handles on each clip. Minimum length 60 ms.
- **Reorder** by long-press and drag.
- **Duplicate** (the copy lands right after the original) and **delete**. At least one clip always remains.
- **Merge** two or more clips into one, through multi-select.
- **Merge** two or more clips into one, through multi-select, which can also paste a copied style onto the selected clips or delete them.
- **Reverse**, as a toggle.
- **Stabilize**, as a toggle: one tap measures the camera shake and bakes the steadied footage into the clip; tapping again restores the original. If the original footage is no longer on the device, the clip stays stabilized and the editor says so rather than offering a retry. It hides the borders only by zooming in, at most 1.15× (`VideoEditorConstants.stabilizationMaxZoom`), so where a clip would need more, some shake stays. A clip with a color mask is steadied from the footage under the key and keyed again.
- **Speed** from 0.25× to 3.0× in 0.05 steps, on a slider, with one-tap presets for 0.25×, 0.5×, 1×, 1.5×, 2× and 3×.
Expand All @@ -55,6 +55,7 @@ Clip actions ([`video_editor_timeline_clip_controls.dart`](../lib/widgets/video_
- **Add clips** from the library or the camera.
- **Volume** per clip, up to 300 % (see [Audio](#audio)). Long-pressing any volume control mutes all clips and sound tracks, or unmutes them if everything is already muted.
- **EQ:** ten octave bands of the clip's own sound (see [Audio](#audio)). Extract audio carries the setting over to the new sound track.
- **Copy style / Paste style:** Copy style takes a clip's volume, EQ, speed, color mask and shape mask; Paste style puts them on the selected clip or, through multi-select, on several clips at once, as one undo step. Pasting makes the target match, so a style copied from a clip without a color mask takes the target's away. A color mask is rendered into each target in turn behind a progress overlay; a clip whose color mask cannot be changed keeps its own, takes the rest, and a message says so. Filters and adjustments are not part of it: they are timeline bars that can span several clips. Copy style is not offered on a freeze frame, a color or photo slot or a stop-motion clip, and a freeze frame takes only the shape mask. The copied style lasts while the editor is open.

Transitions between clips ([`video_editor_transition_sheet.dart`](../lib/widgets/video_editor/timeline_editor/controls/video_editor_transition_sheet.dart)):

Expand Down Expand Up @@ -108,6 +109,8 @@ Timeline:

Every overlay below sits on the timeline, where it can be moved, trimmed to show for only part of the video, split, duplicated and deleted. Text, stickers, drawings and audio visualizers can also be made see-through (opacity 0–100 %).

Copy style and Paste style work on texts, stickers, drawings and audio visualizers. Copy style takes a layer's opacity, its enter, leave and loop animations and its shape mask, and for a text also its look: font, colors, background, alignment, size, outline and shadow. Paste style puts it on the selected layer or, through multi-select, on several at once, as one undo step, and makes the target match. Each layer takes what fits: a sticker takes a text's animations but not its look or its letter-by-letter and word-by-word typing, an audio visualizer takes no mask, and a layer with keyframes keeps the opacity its keyframes set. A shape mask keeps its proportions on a differently shaped layer. Hidden areas and detached clips take no style. Multi-select selects any of these layers to paste a style onto or delete; Combine is offered when the selection is two or more drawings, or two or more stickers, none of them animated, keyframed, masked or trimmed. The copied style lasts while the editor is open.

- **Text:** 128 fonts (`VideoEditorConstants.textFontCatalogue`) grouped by style in the picker, left, center or right alignment, four background modes (none, solid, highlight, transparent), 11 preset colors plus a custom picker with recent colors, an outline and a drop shadow (each with its own color and a strength slider, off at the far left). Size is set by pinching the text on the canvas, and lines wrap at the visible edges of the video instead of running off them. Saved title styles keep font, colors, background, alignment, outline, shadow and animations, but not the pinch scale, position or rotation of the layer it came from (a style saved before the text size slider was removed in #9779 can still carry a font size); names are up to 40 characters.
- **Drawing:** pencil, marker, arrow and eraser. A slider sets the brush size from 2 to 40 logical pixels, and each tool keeps its own size; while the slider is dragged, a dot in the middle of the video shows how thick the next stroke will be. Undo and redo inside the tool. Several drawing layers can be merged into one.
- **Censor (blur or pixelate):** the Censor action opens the drawing tools with blur and pixelate; dragging over the video hides that area, and a slider sets how strongly each area hides it, from a quarter to four times the default. Pixelate is only offered where the renderer supports shader image filters (Impeller, not Skia). Each area is its own layer. It cannot be made see-through, animated or keyframed, since any of these could show for a moment what it hides, and it takes no shape mask. The export blurs or pixelates what lies beneath the area, so layers above it, such as text, stay sharp.
Expand Down
172 changes: 172 additions & 0 deletions mobile/lib/blocs/video_editor/clip_editor/clip_editor_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'package:openvine/models/stop_motion_clip_frame.dart';
import 'package:openvine/models/video_editor/clip_chroma_key.dart';
import 'package:openvine/models/video_editor/clip_placeholder_fill.dart';
import 'package:openvine/models/video_editor/clip_stabilization.dart';
import 'package:openvine/models/video_editor/clip_style.dart';
import 'package:openvine/models/video_editor/composition_duration.dart';
import 'package:openvine/models/video_editor/detached_clip_reattach.dart';
import 'package:openvine/models/video_editor/editor_overlay_snapshot.dart';
Expand Down Expand Up @@ -359,6 +360,13 @@ class ClipEditorBloc extends Bloc<ClipEditorEvent, ClipEditorState> {
transformer: sequential(),
);

// Paste a copied style. droppable: a paste that renders a color mask
// covers the action bars, so a second one meanwhile is a stray duplicate.
on<ClipEditorStylePasteRequested>(
_onStylePasteRequested,
transformer: droppable(),
);

// Lift a clip off the timeline and onto the canvas
// sequential (not droppable) so detaching a second clip while the first
// one's placeholder renders is queued rather than silently dropped.
Expand Down Expand Up @@ -3110,6 +3118,170 @@ class ClipEditorBloc extends Bloc<ClipEditorEvent, ClipEditorState> {
if (baked.isNotEmpty) _onCapturedChromaKeysBaked?.call(baked);
}

// === STYLE ===

/// Puts a copied [ClipStyle] on every target clip.
///
/// Color masks go first, one clip at a time, as each is a render or a file
/// swap; a target whose mask cannot be changed keeps the one it had and
/// still takes the rest of the style. Everything then lands in one emit,
/// which the widget layer writes as one undo step.
Future<void> _onStylePasteRequested(
ClipEditorStylePasteRequested event,
Emitter<ClipEditorState> emit,
) async {
final style = event.style;
final targets = [
for (final clip in state.clips)
if (event.clipIds.contains(clip.id) && canPasteClipStyle(clip)) clip,
];
final needsMask = targets.where(style.needsColorMaskChange).toList();
if (targets.every(style.isOn)) return;

// Each mask change, as a write onto the clip it ends up on, so a change
// to that clip during a render elsewhere is kept.
final masks = <String, DivineVideoClip Function(DivineVideoClip)>{};
final renders = <String>[];
var failures = 0;
if (needsMask.isNotEmpty) emit(state.copyWith(isPastingClipStyle: true));
for (final clip in needsMask) {
final mask = await _pastedColorMask(clip, style.chromaKey, emit, renders);
// Leave-during-bake: the renders never landed on a clip.
if (isClosed) {
_deferOrphanedPaths(renders);
return;
}
if (mask == null) {
failures++;
} else {
masks[clip.id] = mask;
}
}

final targetIds = {for (final clip in targets) clip.id};
final before = state.clips;
final after = <DivineVideoClip>[];
final superseded = <DivineVideoClip>[];
var changed = false;
for (final clip in before) {
final mask = masks[clip.id];
if (!targetIds.contains(clip.id) ||
(mask == null && style.hasSettingsOn(clip))) {
after.add(clip);
continue;
}
changed = true;
if (mask != null) superseded.add(clip);
after.add(style.applyTo(mask?.call(clip) ?? clip));
}

emit(
state.copyWith(
clips: changed ? List.unmodifiable(after) : before,
isPastingClipStyle: false,
clearClipStylePasteRenderId: true,
lastClipStylePasteResult: changed || failures > 0
? ClipStylePasteSuccess(
previousClips: before,
colorMaskFailures: failures,
)
: null,
),
);
superseded.forEach(_deferSupersededFiles);
}

/// A write that puts [chromaKey] on [clip], or takes its key off for
/// `null`, as [ClipEditorChromaKeyRequested] and [ClipEditorChromaKeyRemoved]
/// do; `null` when that failed.
///
/// The path of every file it rendered is added to [renders].
Future<DivineVideoClip Function(DivineVideoClip)?> _pastedColorMask(
DivineVideoClip clip,
ClipChromaKey? chromaKey,
Emitter<ClipEditorState> emit,
List<String> renders,
) async {
if (chromaKey == null) return _removedColorMask(clip, renders);

final renderId = ChromaKeyBakeService.renderIdFor(clip.id);
emit(state.copyWith(clipStylePasteRenderId: renderId));
try {
final baked = await _bakeChromaKey(
sourceClip: clip,
chromaKey: chromaKey,
renderId: renderId,
);
if (baked.video.file?.path case final path?) renders.add(path);
final sources = clip.sourcesWithChromaKey(chromaKey);
return (DivineVideoClip current) => current.copyWith(
video: baked.video,
chromaKey: chromaKey,
chromaKeySourcePath: baked.source,
clearCaptureChromaKey: true,
clearForwardVideoPath: true,
clearReversedVideoPath: true,
derivedFrom: sources,
clearDerivedFrom: sources == null,
);
} catch (e, stackTrace) {
addError(switch (e) {
// A backdrop deleted from the library since it was copied is an
// expected outcome, not a defect.
ChromaKeyBackdropMissingException() => e,
StateError() ||
TypeError() ||
RangeError() => Reportable(e, context: '_onStylePasteRequested'),
_ => e,
}, stackTrace);
Log.error(
'❌ Failed to paste the color mask onto clip ${clip.id}: $e',
name: 'ClipEditorBloc',
category: LogCategory.video,
);
return null;
}
}

/// A write that takes [clip]'s color mask off, by putting back the footage
/// it was applied to; `null` when that footage is gone.
Future<DivineVideoClip Function(DivineVideoClip)?> _removedColorMask(
DivineVideoClip clip,
List<String> renders,
) async {
// Only a key recorded with the take, not baked yet: nothing to swap.
if (clip.chromaKey == null) {
return (DivineVideoClip current) =>
current.copyWith(clearCaptureChromaKey: true);
}
final source = clip.chromaKeySourcePath;
if (source == null || !File(source).existsSync()) {
Log.warning(
'⚠️ Cannot take the color mask off clip ${clip.id}: '
'its unmasked video $source is gone',
name: 'ClipEditorBloc',
category: LogCategory.video,
);
return null;
}
// A key baked right after the take also moved the poster onto the
// composite, so take a fresh one from the footage going back in.
final poster = await _posterOf(source, at: clip.thumbnailTimestamp);
if (poster != null) renders.add(poster.path);
final unkeyed = clip.unkeyedSources;
return (DivineVideoClip current) => current.copyWith(
video: EditorVideo.file(source),
derivedFrom: unkeyed,
clearDerivedFrom: unkeyed == null,
thumbnailPath: poster?.path,
thumbnailTimestamp: poster?.timestamp,
clearChromaKey: true,
clearCaptureChromaKey: true,
clearForwardVideoPath: true,
clearReversedVideoPath: true,
);
}

/// Queues every file [superseded] pointed at that the clip list no longer
/// does, so the editor session reclaims it once undo can no longer reach it.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,27 @@ class ClipEditorCapturedChromaKeysBakeRequested extends ClipEditorEvent {
const ClipEditorCapturedChromaKeysBakeRequested();
}

// === STYLE ===

/// Request that every clip in [clipIds] take on [style] (#10258).
///
/// A color mask that differs is baked into each target in turn, or taken off;
/// the other settings are written once those renders are done, so the whole
/// paste reaches the widget layer as one [ClipStylePasteResult] and becomes
/// one undo step.
class ClipEditorStylePasteRequested extends ClipEditorEvent {
const ClipEditorStylePasteRequested({
required this.clipIds,
required this.style,
});

final List<String> clipIds;
final ClipStyle style;

@override
List<Object?> get props => [clipIds, style];
}

// === DETACH ===

/// Lift the clip with [clipId] out of the timeline so it can be placed freely
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ class ClipEditorState extends Equatable {
this.isBakingCapturedChromaKeys = false,
this.capturedChromaKeyRenderId,
this.lastCapturedChromaKeyBakeResult,
this.isPastingClipStyle = false,
this.clipStylePasteRenderId,
this.lastClipStylePasteResult,
this.isMultiSelectMode = false,
this.selectedClipIds = const {},
this.isMerging = false,
Expand Down Expand Up @@ -245,6 +248,20 @@ class ClipEditorState extends Equatable {
/// identity so the same outcome twice in a row still surfaces.
final CapturedChromaKeyBakeResult? lastCapturedChromaKeyBakeResult;

/// Whether a pasted style is being put on clips.
///
/// Only takes a while when it carries a color mask, which is rendered into
/// each target in turn.
final bool isPastingClipStyle;

/// Render id of the color mask a paste is baking, or `null`. Moves on clip
/// by clip, so the progress follows the render actually running.
final String? clipStylePasteRenderId;

/// Outcome of the last style paste. Compared by identity so the same
/// outcome twice in a row still surfaces.
final ClipStylePasteResult? lastClipStylePasteResult;

/// Whether the timeline is in multi-select mode — tapping a clip toggles its
/// membership in [selectedClipIds] instead of entering single-clip editing.
final bool isMultiSelectMode;
Expand Down Expand Up @@ -421,6 +438,7 @@ class ClipEditorState extends Equatable {
isTransforming ||
isChromaKeying ||
isBakingCapturedChromaKeys ||
isPastingClipStyle ||
isMerging ||
isSavingClipToLibrary ||
isDetaching ||
Expand Down Expand Up @@ -473,6 +491,10 @@ class ClipEditorState extends Equatable {
String? capturedChromaKeyRenderId,
bool clearCapturedChromaKeyRenderId = false,
CapturedChromaKeyBakeResult? lastCapturedChromaKeyBakeResult,
bool? isPastingClipStyle,
String? clipStylePasteRenderId,
bool clearClipStylePasteRenderId = false,
ClipStylePasteResult? lastClipStylePasteResult,
bool clearTransformingClipId = false,
ClipTransformResult? lastTransformResult,
bool? isMultiSelectMode,
Expand Down Expand Up @@ -577,6 +599,12 @@ class ClipEditorState extends Equatable {
lastCapturedChromaKeyBakeResult:
lastCapturedChromaKeyBakeResult ??
this.lastCapturedChromaKeyBakeResult,
isPastingClipStyle: isPastingClipStyle ?? this.isPastingClipStyle,
clipStylePasteRenderId: clearClipStylePasteRenderId
? null
: (clipStylePasteRenderId ?? this.clipStylePasteRenderId),
lastClipStylePasteResult:
lastClipStylePasteResult ?? this.lastClipStylePasteResult,
isMultiSelectMode: isMultiSelectMode ?? this.isMultiSelectMode,
selectedClipIds: selectedClipIds ?? this.selectedClipIds,
isMerging: isMerging ?? this.isMerging,
Expand Down Expand Up @@ -684,6 +712,10 @@ class ClipEditorState extends Equatable {
capturedChromaKeyRenderId,
// Identity-only: each CapturedChromaKeyBakeResult is a fresh instance.
identityHashCode(lastCapturedChromaKeyBakeResult),
isPastingClipStyle,
clipStylePasteRenderId,
// Identity-only: each ClipStylePasteResult is a fresh instance.
identityHashCode(lastClipStylePasteResult),
isMultiSelectMode,
selectedClipIds,
isMerging,
Expand Down Expand Up @@ -870,6 +902,26 @@ final class CapturedChromaKeyBakeSuccess extends CapturedChromaKeyBakeResult {}
/// own chroma key screen, which opens on them — can try again.
final class CapturedChromaKeyBakeFailure extends CapturedChromaKeyBakeResult {}

/// One-shot signal describing the outcome of a style paste.
sealed class ClipStylePasteResult {}

/// The targets took the style; [ClipEditorState.clips] already holds them.
///
/// [previousClips] is the clip list as it was before the paste, so the widget
/// layer can rebase timeline markers and grow sounds across a speed change,
/// and tell an unchanged list from a changed one. [colorMaskFailures] counts
/// the targets whose color mask could not be put on or taken off; they keep
/// the one they had and take the rest of the style.
final class ClipStylePasteSuccess extends ClipStylePasteResult {
ClipStylePasteSuccess({
required this.previousClips,
this.colorMaskFailures = 0,
});

final List<DivineVideoClip> previousClips;
final int colorMaskFailures;
}

// === TRANSFORM RESULT ===

/// One-shot signal describing the outcome of a transform-render operation.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// ABOUTME: Holds the clip style and the layer style the user last copied in
// ABOUTME: the open editor, for Paste style to write onto other items (#10258).

import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:openvine/models/video_editor/clip_style.dart';
import 'package:openvine/models/video_editor/layer_style.dart';

part 'style_clipboard_state.dart';

/// The editor's style clipboard.
///
/// Lives as long as the editor screen, so a copied style stays within the
/// project it was copied in. A clip style and a layer style are kept apart:
/// neither can be pasted where the other goes, so copying one never throws
/// the other away.
class StyleClipboardCubit extends Cubit<StyleClipboardState> {
/// Creates an empty clipboard.
StyleClipboardCubit() : super(const StyleClipboardState());

/// Keeps [style] for pasting onto clips.
void copyClipStyle(ClipStyle style) => emit(state.copyWith(clipStyle: style));

/// Keeps [style] for pasting onto layers.
void copyLayerStyle(LayerStyle style) =>
emit(state.copyWith(layerStyle: style));
}
Loading
Loading