Skip to content
Merged
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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/desktop/src/components/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { cx } from "cva";
import type { ComponentProps, JSX } from "solid-js";

interface Props extends ComponentProps<typeof KTooltip.Root> {
content: JSX.Element;
content?: JSX.Element;
childClass?: string;
kbd?: string[];
}
Expand Down
84 changes: 64 additions & 20 deletions apps/desktop/src/routes/editor/ConfigSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ const BACKGROUND_COLORS = [
"#A9A9A9", // Dark Gray
"#FFFFFF", // White
"#000000", // Black
"#00000000", // Transparent
];

const BACKGROUND_GRADIENTS = [
Expand Down Expand Up @@ -1467,22 +1468,33 @@ function BackgroundConfig(props: { scrollRef: HTMLDivElement }) {
class="sr-only peer"
name="colorPicker"
onChange={(e) => {
if (e.target.checked) {
backgrounds.color = {
type: "color",
value: hexToRgb(color) ?? [0, 0, 0],
};
setProject(
"background",
"source",
backgrounds.color,
);
}
if (!e.target.checked) return;

const rgbValue = hexToRgb(color);
if (!rgbValue) return;

const [r, g, b, a] = rgbValue;
backgrounds.color = {
type: "color",
value: [r, g, b],
alpha: a,
};

setProject(
"background",
"source",
backgrounds.color,
);
}}
/>
<div
class="rounded-lg transition-all duration-200 cursor-pointer size-8 peer-checked:hover:opacity-100 peer-hover:opacity-70 peer-checked:ring-2 peer-checked:ring-gray-500 peer-checked:ring-offset-2 peer-checked:ring-offset-gray-200"
style={{ "background-color": color }}
style={{
background:
color === "#00000000"
? CHECKERED_BUTTON_BACKGROUND
: color,
}}
/>
</label>
)}
Expand Down Expand Up @@ -2762,7 +2774,11 @@ function RgbInput(props: {
value={rgbToHex(props.value)}
onChange={(e) => {
const value = hexToRgb(e.target.value);
if (value) props.onChange(value);
if (!value) return;

// RgbInput only handles RGB values, so extract RGB part if RGBA is returned
const [r, g, b] = value;
props.onChange([r, g, b]);
}}
/>
<TextInput
Expand All @@ -2775,14 +2791,24 @@ function RgbInput(props: {
setText(e.currentTarget.value);

const value = hexToRgb(e.target.value);
if (value) props.onChange(value);
if (!value) return;

const [r, g, b] = value;
props.onChange([r, g, b]);
}}
onBlur={(e) => {
const value = hexToRgb(e.target.value);
if (value) props.onChange(value);
else {
if (value) {
const [r, g, b] = value;
// RgbInput only handles RGB values, so extract RGB part if RGBA is returned
props.onChange([r, g, b]);
} else {
setText(prevHex);
props.onChange(hexToRgb(text())!);
const fallbackValue = hexToRgb(text());
if (!fallbackValue) return;

const [r, g, b] = fallbackValue;
props.onChange([r, g, b]);
}
}}
/>
Expand All @@ -2797,8 +2823,26 @@ function rgbToHex(rgb: [number, number, number]) {
.toUpperCase()}`;
}

function hexToRgb(hex: string): [number, number, number] | null {
const match = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
function hexToRgb(hex: string): [number, number, number, number] | null {
// Support both 6-digit (RGB) and 8-digit (RGBA) hex colors
const match = hex.match(
/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i,
);
if (!match) return null;
return match.slice(1).map((c) => Number.parseInt(c, 16)) as any;

const [, r, g, b, a] = match;
const rgb = [
Number.parseInt(r, 16),
Number.parseInt(g, 16),
Number.parseInt(b, 16),
] as const;

// If alpha is provided, return RGBA tuple
if (a) {
return [...rgb, Number.parseInt(a, 16)];
}

return [...rgb, 255];
}

const CHECKERED_BUTTON_BACKGROUND = `url("data:image/svg+xml,%3Csvg width='16' height='16' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='8' height='8' fill='%23a0a0a0'/%3E%3Crect x='8' y='8' width='8' height='8' fill='%23a0a0a0'/%3E%3C/svg%3E")`;
127 changes: 78 additions & 49 deletions apps/desktop/src/routes/editor/ExportDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
For,
type JSX,
Match,
mergeProps,
on,
Show,
Switch,
Expand All @@ -24,6 +25,7 @@ import {
import { createStore, produce, reconcile } from "solid-js/store";
import toast from "solid-toast";
import { SignInButton } from "~/components/SignInButton";
import Tooltip from "~/components/Tooltip";
import { authStore } from "~/store";
import { trackEvent } from "~/utils/analytics";
import { createSignInMutation } from "~/utils/auth";
Expand Down Expand Up @@ -92,7 +94,7 @@ export const EXPORT_TO_OPTIONS = [

type ExportFormat = ExportSettings["format"];

export const FORMAT_OPTIONS = [
const FORMAT_OPTIONS = [
{ label: "MP4", value: "Mp4" },
{ label: "GIF", value: "Gif" },
] as { label: string; value: ExportFormat; disabled?: boolean }[];
Expand All @@ -119,7 +121,17 @@ export function ExportDialog() {

const auth = authStore.createQuery();

const [settings, setSettings] = makePersisted(
const hasTransparentBackground = () => {
const backgroundSource =
editorInstance.savedProjectConfig.background.source;
return (
backgroundSource.type === "color" &&
backgroundSource.alpha !== undefined &&
backgroundSource.alpha < 255
);
};

const [_settings, setSettings] = makePersisted(
createStore<Settings>({
format: "Mp4",
fps: 30,
Expand All @@ -130,6 +142,14 @@ export function ExportDialog() {
{ name: "export_settings" },
);

const settings = mergeProps(_settings, () => {
const ret: Partial<Settings> = {};
if (hasTransparentBackground() && _settings.format === "Mp4")
ret.format = "Gif";

return ret;
});

if (!["Mp4", "Gif"].includes(settings.format)) setSettings("format", "Mp4");

// Ensure GIF is not selected when exportTo is "link"
Expand Down Expand Up @@ -530,59 +550,68 @@ export function ExportDialog() {
<div class="flex flex-row gap-2">
<For each={FORMAT_OPTIONS}>
{(option) => {
const isGifDisabled = () =>
option.value === "Gif" && settings.exportTo === "link";
const disabledReason = () => {
if (
option.value === "Mp4" &&
hasTransparentBackground()
)
return "MP4 format does not support transparent backgrounds";
if (
option.value === "Gif" &&
settings.exportTo === "link"
)
return "Shareable links cannot be made from GIFs";
};

return (
<Button
variant="gray"
disabled={isGifDisabled()}
onClick={() => {
if (isGifDisabled()) return;
setSettings(
produce((newSettings) => {
newSettings.format =
option.value as ExportFormat;
<Tooltip
content={disabledReason()}
disabled={disabledReason() === undefined}
>
<Button
variant="gray"
onClick={() => {
setSettings(
produce((newSettings) => {
newSettings.format =
option.value as ExportFormat;

if (
option.value === "Gif" &&
!(
settings.resolution.value === "720p" ||
settings.resolution.value === "1080p"
if (
option.value === "Gif" &&
!(
settings.resolution.value === "720p" ||
settings.resolution.value === "1080p"
)
)
)
newSettings.resolution = {
...RESOLUTION_OPTIONS._720p,
};
newSettings.resolution = {
...RESOLUTION_OPTIONS._720p,
};

if (
option.value === "Gif" &&
GIF_FPS_OPTIONS.every(
(v) => v.value === settings.fps,
if (
option.value === "Gif" &&
GIF_FPS_OPTIONS.every(
(v) => v.value !== settings.fps,
)
)
)
newSettings.fps = 15;
newSettings.fps = 15;

if (
option.value === "Mp4" &&
FPS_OPTIONS.every(
(v) => v.value !== settings.fps,
if (
option.value === "Mp4" &&
FPS_OPTIONS.every(
(v) => v.value !== settings.fps,
)
)
)
newSettings.fps = 30;
}),
);
}}
autofocus={false}
data-selected={settings.format === option.value}
class={
isGifDisabled()
? "opacity-50 cursor-not-allowed"
: ""
}
>
{option.label}
</Button>
newSettings.fps = 30;
}),
);
}}
autofocus={false}
data-selected={settings.format === option.value}
disabled={!!disabledReason()}
>
{option.label}
</Button>
</Tooltip>
);
}}
</For>
Expand Down Expand Up @@ -919,7 +948,7 @@ export function ExportDialog() {
>
<div class="relative">
<a
href={meta().sharing!.link}
href={meta().sharing?.link}
target="_blank"
rel="noreferrer"
class="block"
Expand All @@ -930,7 +959,7 @@ export function ExportDialog() {
setTimeout(() => {
setCopyPressed(false);
}, 2000);
navigator.clipboard.writeText(meta().sharing!.link!);
navigator.clipboard.writeText(meta().sharing?.link!);
}}
variant="dark"
class="flex gap-2 justify-center items-center"
Expand Down
15 changes: 14 additions & 1 deletion apps/desktop/src/routes/editor/Player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,18 @@ export function Player() {
);
}

// CSS for checkerboard grid (adaptive to light/dark mode)
const gridStyle = {
"background-image":
"linear-gradient(45deg, rgba(128,128,128,0.12) 25%, transparent 25%), " +
"linear-gradient(-45deg, rgba(128,128,128,0.12) 25%, transparent 25%), " +
"linear-gradient(45deg, transparent 75%, rgba(128,128,128,0.12) 75%), " +
"linear-gradient(-45deg, transparent 75%, rgba(128,128,128,0.12) 75%)",
"background-size": "40px 40px",
"background-position": "0 0, 0 20px, 20px -20px, -20px 0px",
"background-color": "rgba(200,200,200,0.08)",
};

function PreviewCanvas() {
const { latestFrame } = useEditorContext();

Expand Down Expand Up @@ -393,8 +405,9 @@ function PreviewCanvas() {
style={{
width: `${size().width - padding * 2}px`,
height: `${size().height}px`,
...gridStyle,
}}
class="bg-blue-50 rounded"
class="rounded"
ref={canvasRef}
id="canvas"
width={currentFrame().width}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/utils/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ start_time?: number | null }
export type AuthSecret = { api_key: string } | { token: string; expires: number }
export type AuthStore = { secret: AuthSecret; user_id: string | null; plan: Plan | null; intercom_hash: string | null }
export type BackgroundConfiguration = { source: BackgroundSource; blur: number; padding: number; rounding: number; inset: number; crop: Crop | null; shadow?: number; advancedShadow?: ShadowConfiguration | null; border?: BorderConfiguration | null }
export type BackgroundSource = { type: "wallpaper"; path: string | null } | { type: "image"; path: string | null } | { type: "color"; value: [number, number, number] } | { type: "gradient"; from: [number, number, number]; to: [number, number, number]; angle?: number }
export type BackgroundSource = { type: "wallpaper"; path: string | null } | { type: "image"; path: string | null } | { type: "color"; value: [number, number, number]; alpha?: number } | { type: "gradient"; from: [number, number, number]; to: [number, number, number]; angle?: number }
export type BorderConfiguration = { enabled: boolean; width: number; color: [number, number, number]; opacity: number }
export type Camera = { hide: boolean; mirror: boolean; position: CameraPosition; size: number; zoom_size: number | null; rounding?: number; shadow?: number; advanced_shadow?: ShadowConfiguration | null; shape?: CameraShape }
export type CameraInfo = { device_id: string; model_id: ModelIDType | null; display_name: string }
Expand Down
Loading
Loading