From 10466e122a3e55da7a43ae384461caf9f67a843a Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 18 Sep 2026 18:07:14 +0700 Subject: [PATCH] web: the frame tab straightens the photo by hand --- docker/frontend/src/App.tsx | 86 +++++++++++++++++++++++---- docker/frontend/src/engine/session.ts | 2 + 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index fb2a7b8..bd8a5ef 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -109,7 +109,7 @@ type GroupKey = 'cx' | 'cxb' | 'dr' | 'crop' | 'rotate' | 'style' | 'temp' | 'wm const WM_SUBGROUPS: GroupKey[] = ['wmColor', 'wmSize', 'gpsColor', 'gpsName', 'gpsTime']; const isWmPanel = (g: GroupKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g)); // A slider row opened from a strip returns to it with "<" (COLOR TEMP -> TEMP). -const PARAM_GROUP: Partial> = { temperature: 'temp' }; +const PARAM_GROUP: Partial> = { temperature: 'temp', straighten: 'rotate' }; const CHROME_OPTIONS = [ { v: 'none', d: 'OFF' }, @@ -149,6 +149,7 @@ interface Look { cropRect: CropRect; cropApplied: boolean; rotation: 0 | 90 | 180 | 270; + straighten: number; markOn: boolean; markText: string; markStyle: { color: string; size: number }; @@ -185,6 +186,9 @@ export function Workspace() { const [cropApplied, setCropApplied] = useState(() => session.cropApplied ?? false); const [photoDims, setPhotoDims] = useState<{ w: number; h: number } | null>(null); const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(() => session.rotation ?? 0); + // FRAME's STRAIGHTEN: a fine angle on top of the quarter turn. Same pixels, + // so the photo's own dimensions never change and the crop stays valid. + const [straighten, setStraighten] = useState(() => session.straighten ?? 0); const [openParam, setOpenParam] = useState(null); // The HSL mixer: which hue band the ruler edits, whether the eyedropper is // armed, and the last colour it read off the photo. @@ -213,8 +217,8 @@ export function Workspace() { // the session. Small JSON, so no debounce is worth the risk of losing the last // edit; the photo has its own store (engine/session.ts). useEffect(() => { - saveState({ recipe, simId, frameId, crop, cropRect, cropApplied, rotation, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle }); - }, [recipe, simId, frameId, crop, cropRect, cropApplied, rotation, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle]); + saveState({ recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle }); + }, [recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle]); // Crop has three states: none, framed-but-uncommitted (the frame is draggable // and the whole photo is shown), and applied (the crop alone is rendered). @@ -268,7 +272,7 @@ export function Workspace() { // back returns that filter — and its sim name — instead of guessing PROVIA. const monoPrev = useRef<{ baseFilter: Recipe['baseFilter']; simId: string | null; name: string } | null>(null); const lookRef = useRef(null); - lookRef.current = { recipe, simId, frameId, crop, cropRect, cropApplied, rotation, markOn, markText, markStyle, useGeotag, gpsStyle }; + lookRef.current = { recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, markOn, markText, markStyle, useGeotag, gpsStyle }; // Twenty looks is enough to walk, but the oldest entry is the floor — the look // the session loaded with — and is never the one trimmed, so UNDO always // reaches the start of the session. @@ -292,6 +296,7 @@ export function Workspace() { setCropRect(l.cropRect); setCropApplied(l.cropApplied); setRotation(l.rotation); + setStraighten(l.straighten); setMarkOn(l.markOn); setMarkText(l.markText); setMarkStyle(l.markStyle); @@ -310,6 +315,7 @@ export function Workspace() { cropRect: DEFAULT_CROP_RECT, cropApplied: false, rotation: 0, + straighten: 0, markOn: false, markText: '', markStyle: { color: '#ffffff', size: 1 }, @@ -415,6 +421,7 @@ export function Workspace() { wallframeLandscape: frameId === 'wallframe-landscape', sharpen: true, photoRotation: rotation, + photoStraighten: straighten, cropRect: cropLive ? cropRect : null, watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : null, gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null, @@ -435,7 +442,7 @@ export function Workspace() { }, PREVIEW_DEBOUNCE_MS); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, useGeotag, gps, gpsStyle, markOn, markText, markStyle]); + }, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, straighten, useGeotag, gps, gpsStyle, markOn, markText, markStyle]); // The source copy behind previewBytes, re-made only when previewMax moves: // applying a crop upgrades it, CANCEL/UNDO brings the 1600px one back. @@ -572,6 +579,19 @@ export function Workspace() { [remember, setAdjustment] ); + // FRAME's STRAIGHTEN rides the same one-edit-per-gesture rule as a knob, so + // dragging the ruler is one undo step instead of one per degree. + const setStraightenOnce = useCallback( + (v: number) => { + if (!sliderEditRef.current) { + sliderEditRef.current = true; + remember(); + } + setStraighten(Math.max(-180, Math.min(180, Math.round(v)))); + }, + [remember] + ); + // --- crop ----------------------------------------------------------------- // Picking a ratio only opens the frame; the crop is committed by APPLY. const pickCrop = useCallback( @@ -838,6 +858,7 @@ export function Workspace() { dpi: 300, sharpen: true, photoRotation: rotation, + photoStraighten: straighten, cropRect: cropLive ? cropRect : null, watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } @@ -873,7 +894,7 @@ export function Workspace() { } finally { setExporting(false); } - }, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, place, user, t, strip, photoSettings]); + }, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, straighten, markOn, markText, markStyle, place, user, t, strip, photoSettings]); // SAVE PHOTO: the stage as it stands, uploaded to this account's own folder. // The upload is left clean (no burned strip) so the landing reel can draw its @@ -923,6 +944,7 @@ export function Workspace() { dpi: 300, sharpen: true, photoRotation: rotation, + photoStraighten: straighten, cropRect: cropLive ? cropRect : null, watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : null, gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null, @@ -956,7 +978,7 @@ export function Workspace() { } finally { setSavingPhoto(false); } - }, [user, engine, source, myPhotos, openPhotoId, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, strip, photoSettings, t]); + }, [user, engine, source, myPhotos, openPhotoId, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, straighten, markOn, markText, markStyle, strip, photoSettings, t]); // Reopen one of this account's saved frames: the bytes come back as a File // through the same door an upload uses, then the stored look goes on top. @@ -1078,6 +1100,26 @@ export function Workspace() { const slider: SliderDef | null = useMemo(() => { if (!openParam) return null; + // STRAIGHTEN is not a knob either: it is FRAME's own angle, and its ruler + // spans the full circle so a photo can be levelled either way round. + if (openParam === 'straighten') { + return { + key: 'straighten', + label: 'STRAIGHTEN', + value: straighten, + min: -180, + max: 180, + step: 1, + display: `${straighten > 0 ? '+' : ''}${straighten}°`, + edited: straighten !== 0, + onChange: setStraightenOnce, + onReset: () => setStraightenOnce(0), + onBack: () => { + setOpenParam(null); + setOpenGroup('rotate'); + }, + }; + } // The mixer's three knobs are not in PARAM_DEFS: they read and write one // band of hslBands rather than a flat field of adjustments. if (openParam.startsWith('hsl.')) { @@ -1133,7 +1175,7 @@ export function Workspace() { }, }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [openParam, recipe.adjustments, setAdjustmentOnce, hslBand]); + }, [openParam, recipe.adjustments, setAdjustmentOnce, hslBand, straighten, setStraightenOnce]); // The eyedropper's report: print what was read, point the ruler at that // colour's band, and put the tool away — one pick is one colour. @@ -1184,7 +1226,8 @@ export function Workspace() { options: CROPS.map((c) => ({ v: c, d: c === 'none' ? 'NONE' : c.toUpperCase() })), onPick: (v) => pickCrop(v as CropRatio), }, - // ponytail: no STRAIGHTEN/AUTO — the web export carries photoRotation only. + // RESET levels the whole frame; the quarter turns are what this strip picks, + // the fine angle is the STRAIGHTEN chip below it. rotate: { label: 'ROTATE', off: '0', value: String(rotation), options: [ @@ -1197,6 +1240,8 @@ export function Workspace() { onPick: (v) => { remember(); setRotation(v === 'reset' ? 0 : (Number(v) as 0 | 90 | 180 | 270)); + // RESET levels the whole frame, angle included. + if (v === 'reset') setStraighten(0); }, }, style: { @@ -1328,10 +1373,10 @@ export function Workspace() { return tweaked || Object.keys(a.hslBands ?? {}).length > 0 || (simId ?? 'sim-provia') !== 'sim-provia' - || frameId !== 'none' || crop !== 'none' || rotation !== 0 + || frameId !== 'none' || crop !== 'none' || rotation !== 0 || straighten !== 0 || markOn || markText !== '' || markStyle.color !== '#ffffff' || markStyle.size !== 1 || useGeotag || gpsStyle.color !== '#f59e0b' || !gpsStyle.showName || !gpsStyle.showTime; - }, [recipe, simId, frameId, crop, rotation, markOn, markText, markStyle, useGeotag, gpsStyle]); + }, [recipe, simId, frameId, crop, rotation, straighten, markOn, markText, markStyle, useGeotag, gpsStyle]); const resetChip = (): ChipDef => ({ key: 'reset-all', @@ -1364,12 +1409,14 @@ export function Workspace() { }, })), groupChip('crop'), + // The chip names whichever of the two is off the straight — and both when + // both are. { key: 'grp-rotate', label: 'ROTATE', - value: `${rotation}°`, + value: straighten !== 0 ? `${rotation}° ${straighten > 0 ? '+' : ''}${straighten}°` : `${rotation}°`, active: openGroup === 'rotate', - amberValue: openGroup !== 'rotate' && rotation !== 0, + amberValue: openGroup !== 'rotate' && (rotation !== 0 || straighten !== 0), onClick: () => toggleGroup('rotate'), }, { @@ -1531,6 +1578,19 @@ export function Workspace() { { key: 'crop-cancel', label: t('crop.cancel'), onClick: cancelCrop }, ] : []), + // STRAIGHTEN is the ROTATE strip's one entry that opens a ruler instead of + // picking a value: the quarter turns are choices, the angle is a knob. + ...(g === 'rotate' + ? [ + { + key: 'straighten', + label: 'STRAIGHTEN', + active: openParam === 'straighten', + amberValue: openParam !== 'straighten' && straighten !== 0, + onClick: () => toggleParam('straighten'), + }, + ] + : []), ]; // RECIPES = this account's own files (SAVE RECENT) + what this session made diff --git a/docker/frontend/src/engine/session.ts b/docker/frontend/src/engine/session.ts index 43ccad6..7cd1d10 100644 --- a/docker/frontend/src/engine/session.ts +++ b/docker/frontend/src/engine/session.ts @@ -20,6 +20,8 @@ export interface StudioState { // false = the frame is on screen but not accepted yet (still draggable). cropApplied?: boolean; rotation?: 0 | 90 | 180 | 270; + // FRAME's STRAIGHTEN: degrees off the quarter turn, -180..180. + straighten?: number; gps?: GPSInfo | null; gpsManual?: { lat: string; lng: string }; place?: string;