Skip to content
Closed
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f13f8d5
feat: add video reference support with YouTube URL input and selectio…
dalkerman Jul 9, 2026
bb6a2ec
refactor: move AssetReferenceDto to common DTOs and implement video r…
dalkerman Jul 9, 2026
0c51cd6
feat: add YouTube thumbnail preview functionality for reference video…
dalkerman Jul 9, 2026
40bd93c
feat: add YouTubeInputComponent for URL entry and validation with cli…
dalkerman Jul 9, 2026
1719d87
refactor: introduce shared YouTube utilities and enforce mutual exclu…
dalkerman Jul 9, 2026
9c66b41
style: apply glassmorphism design to YouTube input dialog and remove …
dalkerman Jul 9, 2026
91648ee
feat: filter model selection by video reference capability and auto-s…
dalkerman Jul 9, 2026
8502b54
fix: default image reference index to zero when out of bounds in imag…
dalkerman Jul 9, 2026
16a1b78
feat: disable unsupported models in flow prompt box instead of hiding…
dalkerman Jul 9, 2026
1cb60ff
refactor: move video reference validation from state updates to ngOnC…
dalkerman Jul 9, 2026
8c695d3
refactor: migrate YouTube video ID and thumbnail URL getters to signals
dalkerman Jul 9, 2026
81acc66
refactor: move video-to-image model compatibility check from FlowProm…
dalkerman Jul 9, 2026
9f0cd5f
feat: add YouTube URL validation to CreateImagenDto and expand backgr…
dalkerman Jul 9, 2026
97da836
refactor: convert referenceVideoYoutubeUrl to a signal and improve Yo…
dalkerman Jul 9, 2026
4a0dfc3
fix: raise errors for missing media assets and improve YouTube URL va…
dalkerman Jul 9, 2026
120c6c3
feat: add support for YouTube video assets by updating database schem…
dalkerman Jul 13, 2026
db3b041
feat: add youtube_url to source_assets and implement dynamic aspect r…
dalkerman Jul 15, 2026
3e8524e
refactor: rename youtube_url to external_url in source assets schema …
dalkerman Jul 15, 2026
a876aad
feat: add support for YouTube video assets by integrating external UR…
dalkerman Jul 15, 2026
acc5714
feat: add YouTube video support with iframe embedding and custom safe…
dalkerman Jul 15, 2026
5ea4f96
Merge branch 'develop' into feat/video-to-image-mode
dalkerman Jul 17, 2026
96296c6
feat: update unified_gallery_view schema and refine YouTube URL sanit…
dalkerman Jul 17, 2026
4a386f7
style: apply consistent formatting to regex declaration and return st…
dalkerman Jul 17, 2026
b6c0652
feat: add include_external filter to gallery search and expose it in …
dalkerman Jul 17, 2026
e349c60
refactor: rename reference_video_youtube_url to external_url across i…
dalkerman Jul 17, 2026
23a4f59
refactor: improve YouTube URL parsing robustness and add validation f…
dalkerman Jul 17, 2026
8dd9e58
refactor: centralize YouTube URL extraction logic and update async se…
dalkerman Jul 17, 2026
2b8538d
refactor: clean up imports, handle empty YouTube URLs, and improve so…
dalkerman Jul 17, 2026
0303966
refactor: validate YouTube URLs by requiring successful video ID extr…
dalkerman Jul 17, 2026
14beb42
fix: tighten YouTube hostname validation, enforce video/mp4 mime type…
dalkerman Jul 17, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""add_external_url_to_source_assets

Revision ID: 582bbd507011
Revises: 7ec3aed70c3f
Create Date: 2026-07-13 16:06:40.894364

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = "582bbd507011"
down_revision: Union[str, None] = "7ec3aed70c3f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"source_assets", sa.Column("external_url", sa.String(), nullable=True)
)
op.alter_column(
"source_assets", "gcs_uri", existing_type=sa.VARCHAR(), nullable=True
)
op.alter_column(
"source_assets",
"original_filename",
existing_type=sa.VARCHAR(),
nullable=True,
)
op.alter_column(
"source_assets", "mime_type", existing_type=sa.VARCHAR(), nullable=True
)
op.alter_column(
"source_assets", "file_hash", existing_type=sa.VARCHAR(), nullable=True
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"source_assets", "file_hash", existing_type=sa.VARCHAR(), nullable=False
)
op.alter_column(
"source_assets", "mime_type", existing_type=sa.VARCHAR(), nullable=False
)
op.alter_column(
"source_assets",
"original_filename",
existing_type=sa.VARCHAR(),
nullable=False,
)
op.alter_column(
"source_assets", "gcs_uri", existing_type=sa.VARCHAR(), nullable=False
)
op.drop_column("source_assets", "external_url")
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""update_unified_gallery_view_external_url

Revision ID: 7a8b9c0d1e2f
Revises: 582bbd507011
Create Date: 2026-07-15 03:40:00.000000

"""
from typing import Sequence, Union

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "7a8b9c0d1e2f"
down_revision: Union[str, None] = "582bbd507011"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.execute("DROP VIEW IF EXISTS unified_gallery_view;")
op.execute(
"""
CREATE VIEW unified_gallery_view AS
WITH unified_base AS (
SELECT
mi.id,
mi.workspace_id,
mi.user_id,
mi.created_at,
'media_item'::text AS item_type,
mi.status,
mi.gcs_uris,
mi.thumbnail_uris,
mi.deleted_at,
mi.titles,
mi.descriptions,
jsonb_build_object(
'model', mi.model,
'prompt', mi.prompt,
'original_prompt', mi.original_prompt,
'negative_prompt', mi.negative_prompt,
'aspect_ratio', mi.aspect_ratio,
'mime_type', mi.mime_type,
'style', mi.style,
'lighting', mi.lighting,
'num_media', mi.num_media,
'generation_time', mi.generation_time,
'file_name', mi.comment,
'source_assets', mi.source_assets,
'source_media_items', mi.source_media_items,
'is_video', (mi.mime_type LIKE 'video%'),
'is_audio', (mi.mime_type LIKE 'audio%'),
'tags', (
SELECT jsonb_agg(jsonb_build_object('id', t.id, 'name', t.name, 'color', t.color, 'workspace_id', t.workspace_id))
FROM media_item_tags mit
JOIN tags t ON mit.tag_id = t.id
WHERE mit.media_item_id = mi.id
)
) AS metadata
FROM media_items mi
UNION ALL
SELECT
sa.id,
sa.workspace_id,
sa.user_id,
sa.created_at,
'source_asset'::text AS item_type,
'completed'::text AS status,
CASE
WHEN (sa.gcs_uri IS NOT NULL) THEN ARRAY[sa.gcs_uri]
ELSE '{}'::text[]
END AS gcs_uris,
CASE
WHEN (sa.thumbnail_gcs_uri IS NOT NULL) THEN ARRAY[sa.thumbnail_gcs_uri]
ELSE '{}'::text[]
END AS thumbnail_uris,
sa.deleted_at,
sa.titles,
sa.descriptions,
jsonb_build_object(
'file_name', sa.original_filename,
'original_filename', sa.original_filename,
'mime_type', sa.mime_type,
'aspect_ratio', sa.aspect_ratio,
'asset_type', sa.asset_type,
'external_url', sa.external_url,
'is_video', (sa.mime_type LIKE 'video%' OR sa.asset_type = 'youtube_video' OR sa.external_url IS NOT NULL),
'is_audio', (sa.mime_type LIKE 'audio%'),
'tags', (
SELECT jsonb_agg(jsonb_build_object('id', t.id, 'name', t.name, 'color', t.color, 'workspace_id', t.workspace_id))
FROM source_asset_tags sat
JOIN tags t ON sat.tag_id = t.id
WHERE sat.source_asset_id = sa.id
)
) AS metadata
FROM source_assets sa
)
SELECT
ub.*,
w.name AS workspace_name,
u.picture AS user_picture,
u.email AS user_email
FROM unified_base ub
LEFT JOIN workspaces w ON ub.workspace_id = w.id
LEFT JOIN users u ON ub.user_id = u.id;
"""
)


def downgrade() -> None:
op.execute("DROP VIEW IF EXISTS unified_gallery_view;")
op.execute(
"""
CREATE VIEW unified_gallery_view AS
SELECT
mi.id,
mi.workspace_id,
mi.user_id,
mi.created_at,
'media_item'::text AS item_type,
mi.status,
mi.gcs_uris,
mi.thumbnail_uris,
mi.deleted_at,
w.name AS workspace_name,
u.picture AS user_picture,
u.email AS user_email,
mi.titles,
mi.descriptions,
jsonb_build_object(
'model', mi.model,
'prompt', mi.prompt,
'original_prompt', mi.original_prompt,
'negative_prompt', mi.negative_prompt,
'aspect_ratio', mi.aspect_ratio,
'mime_type', mi.mime_type,
'style', mi.style,
'lighting', mi.lighting,
'num_media', mi.num_media,
'generation_time', mi.generation_time,
'file_name', mi.comment,
'source_assets', mi.source_assets,
'source_media_items', mi.source_media_items,
'is_video', (mi.mime_type like 'video%'),
'is_audio', (mi.mime_type like 'audio%'),
'tags', (
SELECT jsonb_agg(jsonb_build_object('id', t.id, 'name', t.name, 'color', t.color, 'workspace_id', t.workspace_id))
FROM media_item_tags mit
JOIN tags t ON mit.tag_id = t.id
WHERE mit.media_item_id = mi.id
)
) AS metadata
FROM media_items mi
LEFT JOIN workspaces w ON mi.workspace_id = w.id
LEFT JOIN users u ON mi.user_id = u.id
UNION ALL
SELECT
sa.id,
sa.workspace_id,
sa.user_id,
sa.created_at,
'source_asset'::text AS item_type,
'completed'::text AS status,
ARRAY[sa.gcs_uri] AS gcs_uris,
CASE
WHEN (sa.thumbnail_gcs_uri IS NOT NULL) THEN ARRAY[sa.thumbnail_gcs_uri]
ELSE '{}'::text[]
END AS thumbnail_uris,
sa.deleted_at,
w.name AS workspace_name,
u.picture AS user_picture,
u.email AS user_email,
sa.titles,
sa.descriptions,
jsonb_build_object(
'file_name', sa.original_filename,
'original_filename', sa.original_filename,
'mime_type', sa.mime_type,
'aspect_ratio', sa.aspect_ratio,
'asset_type', sa.asset_type,
'is_video', (sa.mime_type like 'video%'),
'is_audio', (sa.mime_type like 'audio%'),
'tags', (
SELECT jsonb_agg(jsonb_build_object('id', t.id, 'name', t.name, 'color', t.color, 'workspace_id', t.workspace_id))
FROM source_asset_tags sat
JOIN tags t ON sat.tag_id = t.id
WHERE sat.source_asset_id = sa.id
)
) AS metadata
FROM source_assets sa
LEFT JOIN workspaces w ON sa.workspace_id = w.id
LEFT JOIN users u ON sa.user_id = u.id;
"""
)
13 changes: 12 additions & 1 deletion backend/src/common/base_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from enum import Enum

from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel


Expand Down Expand Up @@ -267,3 +267,14 @@ class BaseDto(BaseModel):
populate_by_name=True,
from_attributes=True,
)


class AssetReferenceDto(BaseDto):
id: int = Field(description="The ID of the asset.")
type: str = Field(
description="The type of asset: 'source_asset' or 'media_item'."
)
index: int | None = Field(
default=0,
description="The index of the media in the media item (if applicable).",
)
50 changes: 50 additions & 0 deletions backend/src/common/media_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import logging
import os
import pathlib
import re
import subprocess

from PIL import Image as PILImage
Expand Down Expand Up @@ -237,3 +238,52 @@ def get_video_dimensions(video_path: str) -> tuple[int, int]:
width = data["streams"][0]["width"]
height = data["streams"][0]["height"]
return width, height


def extract_youtube_video_id(url: str | None) -> str | None:
"""Extracts the 11-character video ID from a YouTube URL."""
if not url:
return None
trimmed = url.strip()
try:
from urllib.parse import urlparse, parse_qs

parsed = urlparse(trimmed)
hostname = parsed.hostname.lower() if parsed.hostname else ""

is_youtube = any(
h in hostname for h in ("youtube.com", "youtube-nocookie.com")
)
is_short = "youtu.be" in hostname
Comment thread
dalkerman marked this conversation as resolved.

if is_youtube:
if any(p in parsed.path for p in ("/embed/", "/shorts/", "/v/")):
parts = parsed.path.split("/")
for p in parts:
if len(p) == 11:
return p
query_params = parse_qs(parsed.query)
v_list = query_params.get("v")
if v_list and len(v_list[0]) == 11:
return v_list[0]
elif is_short:
path_parts = parsed.path.strip("/").split("/")
if path_parts and len(path_parts[0]) == 11:
return path_parts[0]
except Exception:
pass
# Regex fallback
pattern = r"(?:youtu\.be\/|v\/|u\/\w\/|embed\/|shorts\/|watch\?v=|&v=)([a-zA-Z0-9_-]{11})"
match = re.search(pattern, trimmed)
return match.group(1) if match else None
Comment thread
dalkerman marked this conversation as resolved.
Outdated


def get_youtube_aspect_ratio(url: str | None) -> str:
"""Determines the aspect ratio of a YouTube video based on its URL (e.g., 9:16 for Shorts)."""
from src.common.base_dto import AspectRatioEnum

if not url:
return AspectRatioEnum.RATIO_16_9.value
if "/shorts/" in url:
return AspectRatioEnum.RATIO_9_16.value
return AspectRatioEnum.RATIO_16_9.value
3 changes: 3 additions & 0 deletions backend/src/common/schema/media_item_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ class AssetRoleEnum(str, Enum):
)
VIDEO_REFERENCE = "video_reference" # Video used as omni input reference
AUDIO_REFERENCE = "audio_reference" # Audio used as omni input reference
YOUTUBE_VIDEO_REFERENCE = (
"youtube_video_reference" # YouTube video used as input reference
)


class SourceAssetLink(BaseModel):
Expand Down
3 changes: 2 additions & 1 deletion backend/src/galleries/dto/gallery_response_dto.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ class SourceAssetLinkResponse(SourceAssetLink):
"""Extends the source asset link with a presigned URL and GCS URI for frontend display."""

presigned_url: str
gcs_uri: str
gcs_uri: str | None = None
presigned_thumbnail_url: str | None = None
mime_type: str | None = None
external_url: str | None = None


class SourceMediaItemLinkResponse(SourceMediaItemLink):
Expand Down
Loading
Loading